feat: add global chat selected context (#9216)

* feat: add global chat selected context

* refactor: store workspace context as references

* fix: refresh db context after global mode
This commit is contained in:
centdix
2026-05-19 00:35:08 +02:00
committed by GitHub
parent 29f4bada11
commit 49ebf6f8ba
10 changed files with 236 additions and 83 deletions
@@ -90,7 +90,11 @@
let appTooltipCurrentViewNumber = $state(0)
export function focusInput() {
if (aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW) {
if (
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.GLOBAL
) {
contextTextareaComponent?.focus()
} else {
instructionsTextareaComponent?.focus()
@@ -390,7 +394,7 @@
</script>
<div use:clickOutside class="relative">
{#if aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW}
{#if aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW || aiChatManager.mode === AIMode.GLOBAL}
{#if showContext}
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 no-scrollbar">
<Popover>
@@ -573,7 +573,7 @@ class AIChatManager {
} else if (this.mode === AIMode.NAVIGATOR) {
return prepareNavigatorUserMessage(pendingPrompt)
} else if (this.mode === AIMode.GLOBAL) {
return prepareGlobalUserMessage(pendingPrompt)
return prepareGlobalUserMessage(pendingPrompt, this.contextManager.getSelectedContext())
}
return undefined
},
@@ -750,7 +750,7 @@ class AIChatManager {
role: 'user',
content: this.instructions,
contextElements:
this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW
this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW || this.mode === AIMode.GLOBAL
? oldSelectedContext
: undefined,
snapshot,
@@ -790,7 +790,7 @@ class AIChatManager {
userMessage = prepareApiUserMessage(oldInstructions)
break
case AIMode.GLOBAL:
userMessage = prepareGlobalUserMessage(oldInstructions)
userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext)
break
case AIMode.APP:
userMessage = prepareAppUserMessage(
@@ -1035,6 +1035,11 @@ class AIChatManager {
!copilotSessionModel?.model.endsWith('/thinking'),
untrack(() => this.contextManager.getSelectedContext())
)
} else if (this.mode === AIMode.GLOBAL) {
this.contextManager.updateAvailableContextForGlobal(
workspaceStore ?? '',
untrack(() => this.contextManager.getSelectedContext())
)
}
if (this.scriptEditorOptions) {
@@ -3,7 +3,7 @@
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import type { FlowModule } from '$lib/gen/types.gen'
import { workspaceStore } from '$lib/stores'
import { workspaceRunnablesSearch, MAX_RUNNABLE_CONTENT_LENGTH } from './shared'
import { workspaceRunnablesSearch } from './shared'
import {
ContextIconMap,
type ContextElement,
@@ -167,54 +167,31 @@
}, 300)
}
async function handleWorkspaceItemSelect(path: string) {
const workspace = $workspaceStore
if (!workspace || !onSelectWorkspaceItem) return
function handleWorkspaceItemSelect(item: { path: string; summary?: string }) {
if (!onSelectWorkspaceItem) return
try {
if (currentView === 'scripts') {
const script = await workspaceRunnablesSearch.getScript(path, workspace)
const content = script.content ?? ''
const truncatedContent =
content.length > MAX_RUNNABLE_CONTENT_LENGTH
? content.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)'
: content
const element: WorkspaceScriptElement & { deletable: boolean } = {
type: 'workspace_script',
path: script.path,
title: script.path,
summary: script.summary,
language: script.language,
content: truncatedContent,
schema: script.schema,
deletable: true
}
onSelectWorkspaceItem(element)
} else if (currentView === 'flows') {
const flow = await workspaceRunnablesSearch.getFlow(path, workspace)
const flowValue = JSON.stringify(flow.value, null, 2)
const truncatedValue =
flowValue.length > MAX_RUNNABLE_CONTENT_LENGTH
? flowValue.slice(0, MAX_RUNNABLE_CONTENT_LENGTH) + '\n... (truncated)'
: flowValue
const element: WorkspaceFlowElement & { deletable: boolean } = {
type: 'workspace_flow',
path: flow.path,
title: flow.path,
summary: flow.summary,
description: flow.description || '',
value: truncatedValue,
schema: flow.schema,
deletable: true
}
onSelectWorkspaceItem(element)
if (currentView === 'scripts') {
const element: WorkspaceScriptElement & { deletable: boolean } = {
type: 'workspace_script',
path: item.path,
title: item.path,
summary: item.summary,
deletable: true
}
currentView = 'categories'
workspaceSearchQuery = ''
workspaceSearchResults = []
} catch (err) {
console.error('Error fetching workspace item', err)
onSelectWorkspaceItem(element)
} else if (currentView === 'flows') {
const element: WorkspaceFlowElement & { deletable: boolean } = {
type: 'workspace_flow',
path: item.path,
title: item.path,
summary: item.summary,
deletable: true
}
onSelectWorkspaceItem(element)
}
currentView = 'categories'
workspaceSearchQuery = ''
workspaceSearchResults = []
}
function handleKeyDown(e: KeyboardEvent) {
@@ -241,7 +218,7 @@
e.stopPropagation()
const selectedItem = workspaceSearchResults[itemSelectedIndex]
if (selectedItem) {
handleWorkspaceItemSelect(selectedItem.path)
handleWorkspaceItemSelect(selectedItem)
}
}
} else if (e.key === 'Escape') {
@@ -358,7 +335,7 @@
>
{#if stringSearch.length > 0}
<!-- Search view - show flat list -->
{#each filteredAvailableContext as element, i}
{#each filteredAvailableContext as element, i (element.type + '-' + element.title)}
{@const Icon = ContextIconMap[element.type]}
<button
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-row gap-1 items-center font-normal transition-colors {i ===
@@ -386,7 +363,7 @@
{/if}
{:else if currentView === 'categories'}
<!-- Categories view -->
{#each availableCategories as category, i}
{#each availableCategories as category, i (category.id)}
{@const Icon = category.icon}
<button
class="hover:bg-surface-hover rounded-md p-1 pr-0 text-left flex flex-row gap-1 items-center font-normal transition-colors {i ===
@@ -432,7 +409,7 @@
No results found
</div>
{:else}
{#each workspaceSearchResults as item, i}
{#each workspaceSearchResults as item, i (currentView + '-' + item.path)}
{@const isAlreadySelected = selectedContext.some(
(c) =>
((c.type === 'workspace_script' && currentView === 'scripts') ||
@@ -446,7 +423,7 @@
: ''} {isAlreadySelected ? 'opacity-50' : ''}"
onclick={() => {
if (!isAlreadySelected) {
handleWorkspaceItemSelect(item.path)
handleWorkspaceItemSelect(item)
}
}}
disabled={isAlreadySelected}
@@ -478,7 +455,7 @@
{#if currentCategoryItems.length === 0}
<div class="text-center text-primary text-xs py-2">No items in this category</div>
{:else}
{#each currentCategoryItems as element, i}
{#each currentCategoryItems as element, i (element.type + '-' + element.title)}
{@const Icon = ContextIconMap[element.type]}
<button
class="hover:bg-surface-hover rounded-md p-1 text-left flex flex-row gap-1 items-center font-normal transition-colors {i ===
@@ -34,6 +34,7 @@ export default class ContextManager {
private availableContext: ContextElement[] = $state([])
private workspace: string | undefined = undefined
private dbResourcesWorkspace: string | undefined = undefined
private dbResources: ListResourceResponse = []
private scriptOptions: ScriptOptions | undefined = undefined
@@ -42,6 +43,7 @@ export default class ContextManager {
workspace: workspace,
resourceType: SQLSchemaLanguages.join(',')
})
this.dbResourcesWorkspace = workspace
}
private getSelectedDBSchema(scriptOptions: ScriptOptions, dbSchemas: DBSchemas) {
@@ -66,6 +68,19 @@ export default class ContextManager {
)
}
updateAvailableContextForGlobal(workspace: string, currentlySelectedContext: ContextElement[]) {
this.availableContext = []
if (!workspace || (this.workspace !== undefined && this.workspace !== workspace)) {
this.workspace = workspace
this.selectedContext = []
return
}
this.workspace = workspace
this.selectedContext = currentlySelectedContext.filter(
(context) => context.type === 'workspace_script' || context.type === 'workspace_flow'
)
}
async updateAvailableContextForFlow(
flowOptions: FlowOptions,
dbSchemas: DBSchemas,
@@ -74,10 +89,10 @@ export default class ContextManager {
currentlySelectedContext: ContextElement[]
) {
try {
if (this.workspace !== workspace) {
if (this.dbResourcesWorkspace !== workspace) {
await this.refreshDbResources(workspace)
this.workspace = workspace
}
this.workspace = workspace
let newAvailableContext: ContextElement[] = []
@@ -161,10 +176,10 @@ export default class ContextManager {
currentlySelectedContext: ContextElement[]
) {
try {
if (this.workspace !== workspace) {
if (this.dbResourcesWorkspace !== workspace) {
await this.refreshDbResources(workspace)
this.workspace = workspace
}
this.workspace = workspace
let newAvailableContext: ContextElement[] = [
{
type: 'code',
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { listResource } = vi.hoisted(() => ({
listResource: vi.fn()
}))
vi.mock('$lib/gen', () => ({
ResourceService: {
listResource
}
}))
vi.mock('$lib/scripts', () => ({
scriptLangToEditorLang: (language: string) => language
}))
vi.mock('$lib/stores', () => ({
SQLSchemaLanguages: ['postgresql']
}))
vi.mock('$lib/editorLangUtils', () => ({
langToExt: () => 'ts'
}))
describe('ContextManager', () => {
beforeEach(() => {
listResource.mockReset()
})
const scriptOptions = {
lang: 'bun' as const,
code: 'export async function main() {}',
error: undefined,
args: {},
path: 'f/scripts/example',
diffMode: false
}
it('loads db resources when switching from global to script in the same workspace', async () => {
const { default: ContextManager } = await import('./ContextManager.svelte')
const manager = new ContextManager()
listResource.mockResolvedValueOnce([{ path: 'f/db/main' }])
manager.updateAvailableContextForGlobal('workspace-a', [])
await manager.updateAvailableContext(scriptOptions, {}, 'workspace-a', true, [])
expect(listResource).toHaveBeenCalledWith({
workspace: 'workspace-a',
resourceType: 'postgresql'
})
expect(manager.getAvailableContext()).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'db', title: 'f/db/main' })])
)
})
it('refreshes db resources after switching workspaces through global mode', async () => {
const { default: ContextManager } = await import('./ContextManager.svelte')
const manager = new ContextManager()
listResource
.mockResolvedValueOnce([{ path: 'f/db/workspace-a' }])
.mockResolvedValueOnce([{ path: 'f/db/workspace-b' }])
await manager.updateAvailableContext(scriptOptions, {}, 'workspace-a', true, [])
manager.updateAvailableContextForGlobal('workspace-b', [])
await manager.updateAvailableContext(scriptOptions, {}, 'workspace-b', true, [])
expect(listResource).toHaveBeenCalledTimes(2)
expect(listResource).toHaveBeenLastCalledWith({
workspace: 'workspace-b',
resourceType: 'postgresql'
})
expect(manager.getAvailableContext()).toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'db', title: 'f/db/workspace-b' })])
)
expect(manager.getAvailableContext()).not.toEqual(
expect.arrayContaining([expect.objectContaining({ type: 'db', title: 'f/db/workspace-a' })])
)
})
})
@@ -9,7 +9,7 @@ import {
Table2
} from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import type { ScriptLang, Script, OpenFlow } from '$lib/gen/types.gen'
import type { ScriptLang } from '$lib/gen/types.gen'
import { type DBSchema } from '$lib/stores'
import { type Change } from 'diff'
import type { AppDatatableMetadata, BackendRunnable, SelectedContext } from './app/core'
@@ -213,20 +213,19 @@ export function flattenDatatablesToAppContextElements(
}
/** Workspace script context element — reference to a script in the workspace */
export interface WorkspaceScriptElement
extends Pick<Script, 'path' | 'summary' | 'language' | 'content' | 'schema'> {
export interface WorkspaceScriptElement {
type: 'workspace_script'
path: string
title: string
summary?: string
}
/** Workspace flow context element — reference to a flow in the workspace */
export interface WorkspaceFlowElement extends Pick<OpenFlow, 'summary' | 'schema'> {
export interface WorkspaceFlowElement {
type: 'workspace_flow'
path: string
title: string
description: string
/** Full flow value, JSON-stringified and possibly truncated */
value: string
summary?: string
}
export type ContextElement = (
@@ -51,7 +51,7 @@ vi.mock('$lib/gen', async () => {
}
})
import { globalTools } from './core'
import { globalTools, prepareGlobalUserMessage } from './core'
import { globalDraftStore } from './draftStore.svelte'
import type { Tool, ToolCallbacks } from '../shared'
@@ -238,3 +238,35 @@ describe('global AI tools', () => {
)
})
})
describe('prepareGlobalUserMessage', () => {
it('includes selected workspace item references without contents', () => {
const message = prepareGlobalUserMessage('Update these items', [
{
type: 'workspace_script',
path: 'f/scripts/report',
title: 'f/scripts/report',
summary: 'Report script'
},
{
type: 'workspace_flow',
path: 'f/flows/reporting',
title: 'f/flows/reporting',
summary: 'Reporting flow'
}
])
expect(message.content).toContain('## SELECTED CONTEXT')
expect(message.content).toContain('- type: script, path: f/scripts/report')
expect(message.content).toContain('- type: flow, path: f/flows/reporting')
expect(message.content).toContain('## INSTRUCTIONS:\nUpdate these items')
expect(message.content).not.toContain('Report script')
expect(message.content).not.toContain('Reporting flow')
})
it('omits selected context section when no workspace item is selected', () => {
const message = prepareGlobalUserMessage('Create a draft')
expect(message.content).toBe('## INSTRUCTIONS:\nCreate a draft')
})
})
@@ -60,6 +60,7 @@ import {
type ToolCallbacks,
type ToolDisplayAction
} from '../shared'
import type { ContextElement } from '../context'
import {
resourceRequestSchema,
scheduleRequestSchema,
@@ -2480,9 +2481,27 @@ export function prepareGlobalSystemMessage(
}
}
export function prepareGlobalUserMessage(instructions: string): ChatCompletionUserMessageParam {
export function prepareGlobalUserMessage(
instructions: string,
selectedContext: ContextElement[] = []
): ChatCompletionUserMessageParam {
const selectedWorkspaceItems = selectedContext.filter(
(context) => context.type === 'workspace_script' || context.type === 'workspace_flow'
)
let content = ''
if (selectedWorkspaceItems.length > 0) {
content += '## SELECTED CONTEXT\n'
for (const context of selectedWorkspaceItems) {
content += `- type: ${context.type === 'workspace_script' ? 'script' : 'flow'}, path: ${context.path}\n`
}
content += '\n'
}
content += `## INSTRUCTIONS:\n${instructions}`
return {
role: 'user',
content: instructions
content
}
}
@@ -119,6 +119,35 @@ describe('createToolDef', () => {
})
})
describe('buildContextString', () => {
it('serializes selected workspace items as references only', async () => {
const { buildContextString } = await import('./shared')
const context = buildContextString([
{
type: 'workspace_script',
path: 'f/scripts/report',
title: 'f/scripts/report',
summary: 'Report script'
},
{
type: 'workspace_flow',
path: 'f/flows/reporting',
title: 'f/flows/reporting',
summary: 'Reporting flow'
}
])
expect(context).toContain('SELECTED WORKSPACE ITEMS:')
expect(context).toContain('- type: script, path: f/scripts/report')
expect(context).toContain('- type: flow, path: f/flows/reporting')
expect(context).not.toContain('Report script')
expect(context).not.toContain('Reporting flow')
expect(context).not.toContain('Code:')
expect(context).not.toContain('Value:')
})
})
describe('processToolCall', () => {
it('returns pre-confirmation validation errors without asking for confirmation', async () => {
const { createToolDef, processToolCall } = await import('./shared')
@@ -410,21 +410,15 @@ export function buildContextString(selectedContext: ContextElement[]): string {
hasFlowModule = true
flowModuleContext += `${context.id}\n`
} else if (context.type === 'workspace_script') {
workspaceItemsContext += `\nWORKSPACE SCRIPT (${context.path}):\n`
workspaceItemsContext += `Summary: ${context.summary}\n`
workspaceItemsContext += `Language: ${context.language}\n`
if (context.schema) {
workspaceItemsContext += `Inputs: ${JSON.stringify(context.schema)}\n`
if (!workspaceItemsContext) {
workspaceItemsContext = 'SELECTED WORKSPACE ITEMS:\n'
}
workspaceItemsContext += `Code:\n${context.content}\n`
workspaceItemsContext += `- type: script, path: ${context.path}\n`
} else if (context.type === 'workspace_flow') {
workspaceItemsContext += `\nWORKSPACE FLOW (${context.path}):\n`
workspaceItemsContext += `Summary: ${context.summary}\n`
workspaceItemsContext += `Description: ${context.description}\n`
if (context.schema) {
workspaceItemsContext += `Inputs: ${JSON.stringify(context.schema)}\n`
if (!workspaceItemsContext) {
workspaceItemsContext = 'SELECTED WORKSPACE ITEMS:\n'
}
workspaceItemsContext += `Value:\n${context.value}\n`
workspaceItemsContext += `- type: flow, path: ${context.path}\n`
}
}