From f31c68e8bee85e9dcbaabf6516961ef9904b75ea Mon Sep 17 00:00:00 2001 From: centdix Date: Mon, 13 Apr 2026 12:32:08 +0200 Subject: [PATCH] refactor: clean up app chat selection Co-Authored-By: Claude Opus 4.5 --- .../copilot/chat/AIChatDisplay.svelte | 6 +- .../copilot/chat/AIChatManager.svelte.ts | 22 +--- .../copilot/chat/ContextManager.svelte.ts | 11 +- .../chat/__tests__/app/appEvalHelpers.ts | 17 +-- .../chat/__tests__/app/appEvalRunner.ts | 2 +- .../lib/components/copilot/chat/app/core.ts | 120 +++++++++++------- .../components/raw_apps/RawAppEditor.svelte | 35 +---- 7 files changed, 103 insertions(+), 110 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 3d2b84931d..e65f54c9f1 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -24,7 +24,7 @@ import { aiChatManager, AIMode } from './AIChatManager.svelte' import AIChatInput from './AIChatInput.svelte' import { getModifierKey } from '$lib/utils' - import type { SelectedContext } from './app/core' + import type { AppTransientContext } from './app/core' let { messages, @@ -101,11 +101,11 @@ ) // Get app context for display when in APP mode - const appContext = $derived.by((): SelectedContext | undefined => { + const appContext = $derived.by((): AppTransientContext | undefined => { if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) { return undefined } - return aiChatManager.appAiChatHelpers.getSelectedContext() + return aiChatManager.appAiChatHelpers.getTransientContext() }) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index b830c52fcb..135358994e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -12,7 +12,7 @@ import { prepareAppSystemMessage, prepareAppUserMessage, type AppAIChatHelpers, - type SelectedContext + type AppSelection } from './app/core' import ContextManager from './ContextManager.svelte' import HistoryManager from './HistoryManager.svelte' @@ -446,10 +446,7 @@ class AIChatManager { if (!pendingPrompt) return undefined this.pendingPrompt = '' if (this.mode === AIMode.SCRIPT) { - return prepareScriptUserMessage( - pendingPrompt, - this.contextManager.getSelectedContext() - ) + return prepareScriptUserMessage(pendingPrompt, this.contextManager.getSelectedContext()) } else if (this.mode === AIMode.FLOW) { return prepareFlowUserMessage( pendingPrompt, @@ -628,9 +625,7 @@ class AIChatManager { role: 'user', content: this.instructions, contextElements: - this.mode === AIMode.SCRIPT || - this.mode === AIMode.FLOW || - this.mode === AIMode.APP + this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW || this.mode === AIMode.APP ? oldSelectedContext : undefined, snapshot, @@ -671,7 +666,7 @@ class AIChatManager { case AIMode.APP: userMessage = prepareAppUserMessage( oldInstructions, - this.appAiChatHelpers?.getSelectedContext(), + this.appAiChatHelpers?.getTransientContext(), oldSelectedContext ) break @@ -918,18 +913,13 @@ class AIChatManager { } } - syncAppSelection = ( - selectedContext: Pick | undefined - ) => { + syncAppSelection = (selectedContext: AppSelection | undefined) => { const availableContext = this.getAppAvailableContext() this.contextManager.updateAvailableContextForApp( availableContext, untrack(() => this.contextManager.getSelectedContext()) ) - this.contextManager.setSelectedAppContext( - selectedContext, - availableContext - ) + this.contextManager.setSelectedAppContext(selectedContext, availableContext) } listenForDbSchemasChanges = (dbSchemas: DBSchemas) => { diff --git a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts index 0625630407..a3c9dcb063 100644 --- a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts @@ -8,7 +8,7 @@ import type { FlowModule } from '$lib/gen' import type { DisplayMessage } from './shared' import { langToExt } from '$lib/editorLangUtils' import type { ExtendedOpenFlow } from '$lib/components/flows/types' -import type { SelectedContext } from './app/core' +import type { AppSelection } from './app/core' export interface ScriptOptions { lang: ScriptLang | 'bunnative' @@ -458,7 +458,7 @@ export default class ContextManager { } setSelectedAppContext( - selectedContext: Pick | undefined, + selectedContext: AppSelection | undefined, availableContext: ContextElement[] | undefined ) { this.selectedContext = this.selectedContext.filter( @@ -477,15 +477,14 @@ export default class ContextManager { selectedContext?.type === 'frontend' && selectedContext.frontendPath ? availableContext.find( (context) => - context.type === 'app_frontend_file' && - context.title === selectedContext.frontendPath - ) + context.type === 'app_frontend_file' && context.title === selectedContext.frontendPath + ) : selectedContext?.type === 'backend' && selectedContext.backendKey ? availableContext.find( (context) => context.type === 'app_backend_runnable' && context.title === selectedContext.backendKey - ) + ) : undefined if (selectedAppContext) { diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalHelpers.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalHelpers.ts index 4dbc8d58a0..268444b637 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalHelpers.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalHelpers.ts @@ -3,7 +3,7 @@ import type { AppFiles, BackendRunnable, LintResult, - SelectedContext + AppTransientContext } from '../../app/core' /** @@ -30,7 +30,10 @@ export function createAppEvalHelpers( let frontend: Record = { ...initialFrontend } let backend: Record = { ...initialBackend } let snapshotId = 0 - const snapshots: Map; backend: Record }> = new Map() + const snapshots: Map< + number, + { frontend: Record; backend: Record } + > = new Map() const helpers: AppAIChatHelpers = { // Frontend file operations @@ -78,9 +81,7 @@ export function createAppEvalHelpers( backend: { ...backend } }), - getSelectedContext: (): SelectedContext => ({ - type: 'none' - }), + getTransientContext: (): AppTransientContext => ({}), // Snapshot management snapshot: () => { @@ -126,11 +127,7 @@ export function createAppEvalHelpers( return { success: true, result: [] } }, - addTableToWhitelist: ( - _datatableName: string, - _schemaName: string, - _tableName: string - ) => { + addTableToWhitelist: (_datatableName: string, _schemaName: string, _tableName: string) => { // No-op for eval testing - tables are not tracked in test context } } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts index 9bb52a3eb9..90c6d41313 100644 --- a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts @@ -75,7 +75,7 @@ export async function runAppEval( const model = resolveModel(options?.variant, options?.model) // Build user message - const userMessage = prepareAppUserMessage(userPrompt, helpers.getSelectedContext(), []) + const userMessage = prepareAppUserMessage(userPrompt, helpers.getTransientContext(), []) // Run the base evaluation const rawResult = await runEval({ diff --git a/frontend/src/lib/components/copilot/chat/app/core.ts b/frontend/src/lib/components/copilot/chat/app/core.ts index cab365ca67..8778dd0612 100644 --- a/frontend/src/lib/components/copilot/chat/app/core.ts +++ b/frontend/src/lib/components/copilot/chat/app/core.ts @@ -82,18 +82,22 @@ export interface InspectorElementInfo { styles: Record } -/** Context about the currently selected file or runnable in the app editor */ -export interface SelectedContext { - /** Type of selection: 'frontend' for frontend files, 'backend' for backend runnables, or 'none' if nothing is selected */ - type: 'frontend' | 'backend' | 'none' - /** The path of the selected frontend file (when type is 'frontend') */ - frontendPath?: string - /** The content of the selected frontend file */ - frontendContent?: string - /** The key of the selected backend runnable (when type is 'backend') */ - backendKey?: string - /** The configuration of the selected backend runnable */ - backendRunnable?: BackendRunnable +/** Current file or runnable selected in the app editor */ +export type AppSelection = + | { + type: 'frontend' + frontendPath: string + } + | { + type: 'backend' + backendKey: string + } + | { + type: 'none' + } + +/** Transient app context that does not live in the shared ContextManager selection list */ +export interface AppTransientContext { /** Inspector-selected element info (when user has used the inspector tool) */ inspectorElement?: InspectorElementInfo /** Function to clear the inspector selection */ @@ -132,7 +136,7 @@ export interface AppAIChatHelpers { deleteBackendRunnable: (key: string) => void // Combined view getFiles: () => AppFiles - getSelectedContext: () => SelectedContext + getTransientContext: () => AppTransientContext snapshot: () => number revertToSnapshot: (id: number) => void // Linting @@ -482,12 +486,22 @@ export const getAppTools = memo((): Tool[] => [ def: getGetSelectedContextToolDef(), fn: async ({ helpers, toolId, toolCallbacks }) => { toolCallbacks.setToolStatus(toolId, { content: 'Getting selected context...' }) - const context = helpers.getSelectedContext() + const currentSelection = getCurrentAppSelection( + aiChatManager.contextManager.getSelectedContext() + ) + const transientContext = helpers.getTransientContext() + const context = { + ...currentSelection, + ...(transientContext.inspectorElement + ? { inspectorElement: transientContext.inspectorElement } + : {}), + ...(transientContext.codeSelection ? { codeSelection: transientContext.codeSelection } : {}) + } const statusMsg = - context.type === 'frontend' - ? `Frontend file selected: ${context.frontendPath}` - : context.type === 'backend' - ? `Backend runnable selected: ${context.backendKey}` + currentSelection.type === 'frontend' + ? `Frontend file selected: ${currentSelection.frontendPath}` + : currentSelection.type === 'backend' + ? `Backend runnable selected: ${currentSelection.backendKey}` : 'No selection' toolCallbacks.setToolStatus(toolId, { content: statusMsg }) return JSON.stringify(context, null, 2) @@ -1033,53 +1047,72 @@ function formatAppContextElement( ) } -function getAppContextElements( - additionalContext: ContextElement[] -): { - activeContextElements: ActiveAppContextElement[] - additionalContextElements: AppContextElement[] -} { - const activeContextElements = additionalContext.filter( +function getCurrentAppSelection(selectedContext: ContextElement[]): AppSelection { + const activeContextElement = selectedContext.find( (context) => context.activeSelection && (context.type === 'app_frontend_file' || context.type === 'app_backend_runnable') - ) as ActiveAppContextElement[] - const additionalContextElements = additionalContext.filter( + ) as ActiveAppContextElement | undefined + + if (!activeContextElement) { + return { type: 'none' } + } + + return activeContextElement.type === 'app_frontend_file' + ? { + type: 'frontend', + frontendPath: activeContextElement.path + } + : { + type: 'backend', + backendKey: activeContextElement.key + } +} + +function getSelectedAppContextElement( + selectedContext: ContextElement[] +): ActiveAppContextElement | undefined { + return selectedContext.find( + (context) => + context.activeSelection && + (context.type === 'app_frontend_file' || context.type === 'app_backend_runnable') + ) as ActiveAppContextElement | undefined +} + +function getAdditionalAppContextElements(selectedContext: ContextElement[]): AppContextElement[] { + return selectedContext.filter( (context) => !context.activeSelection && (context.type === 'app_frontend_file' || context.type === 'app_backend_runnable' || context.type === 'app_datatable') ) as AppContextElement[] - - return { activeContextElements, additionalContextElements } } export function prepareAppUserMessage( instructions: string, - selectedContext?: SelectedContext, - additionalContext?: ContextElement[] + transientContext?: AppTransientContext, + selectedContext: ContextElement[] = [] ): ChatCompletionUserMessageParam { let content = '' - const { activeContextElements, additionalContextElements } = getAppContextElements( - additionalContext ?? [] - ) + const activeContextElement = getSelectedAppContextElement(selectedContext) + const additionalContextElements = getAdditionalAppContextElements(selectedContext) - // Check if we have any context to add const hasSelectedContext = - activeContextElements.length > 0 || !!selectedContext?.inspectorElement || !!selectedContext?.codeSelection + !!activeContextElement || + !!transientContext?.inspectorElement || + !!transientContext?.codeSelection const hasAdditionalContext = additionalContextElements.length > 0 if (hasSelectedContext || hasAdditionalContext) { content += `## SELECTED CONTEXT:\n` - for (const activeContextElement of activeContextElements) { + if (activeContextElement) { content += formatAppContextElement(activeContextElement, { activeSelection: true }) } - // Add inspector element context if available - if (selectedContext?.inspectorElement) { - const el = selectedContext.inspectorElement + if (transientContext?.inspectorElement) { + const el = transientContext.inspectorElement content += `\nThe user has selected an element in the app preview using the inspector tool:\n` content += `- **Element**: ${el.tagName}${el.id ? `#${el.id}` : ''}${el.className ? `.${el.className.split(' ').join('.')}` : ''}\n` content += `- **Selector path**: ${el.path}\n` @@ -1089,14 +1122,12 @@ export function prepareAppUserMessage( el.textContent.length > 100 ? el.textContent.slice(0, 100) + '...' : el.textContent content += `- **Text content**: "${truncatedText}"\n` } - // Include HTML (truncated) for more context const truncatedHtml = el.html.length > 500 ? el.html.slice(0, 500) + '...' : el.html content += `- **HTML**:\n\`\`\`html\n${truncatedHtml}\n\`\`\`\n` } - // Add code selection context if available - if (selectedContext?.codeSelection) { - const selection = selectedContext.codeSelection + if (transientContext?.codeSelection) { + const selection = transientContext.codeSelection content += `\n### CODE SELECTION:\n` content += `The user has selected code in the ${selection.sourceType} editor:\n` content += `- **File/Source**: ${selection.source}\n` @@ -1104,7 +1135,6 @@ export function prepareAppUserMessage( content += `\`\`\`\n${truncateContextContent(selection.content)}\n\`\`\`\n` } - // Add additional context from @ mentions if (additionalContextElements.length > 0) { content += `\n### ADDITIONAL CONTEXT (mentioned by user):\n` diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 20f5096f8c..68b68d0ab2 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -22,7 +22,8 @@ LintResult, DataTableSchema, InspectorElementInfo, - SelectedContext + AppSelection, + AppTransientContext } from '../copilot/chat/app/core' import type { AppCodeSelectionElement } from '../copilot/chat/context' import { rawAppLintStore } from './lintStore' @@ -401,7 +402,7 @@ backend: aiChatManager.appAiChatHelpers?.getBackendRunnables() ?? {} } }, - getSelectedContext: () => getSelectedAppContext(), + getTransientContext: () => getAppTransientContext(), snapshot: () => { // Force create snapshot for AI - it needs a restore point return ( @@ -565,8 +566,8 @@ let modules = $state({}) as Modules - function getSelectedAppContext(): SelectedContext { - const baseContext = { + function getAppTransientContext(): AppTransientContext { + return { inspectorElement: inspectorElement, clearInspector: clearInspectorSelection, codeSelection: codeSelection, @@ -574,30 +575,6 @@ codeSelection = undefined } } - - if (selectedRunnable) { - const runnable = convertToBackendRunnable(selectedRunnable, runnables[selectedRunnable]) - return { - type: 'backend' as const, - backendKey: selectedRunnable, - backendRunnable: runnable, - ...baseContext - } - } - - if (selectedDocument) { - return { - type: 'frontend' as const, - frontendPath: selectedDocument, - frontendContent: files?.[selectedDocument], - ...baseContext - } - } - - return { - type: 'none' as const, - ...baseContext - } } // Normalize Windows-style path separators to Linux-style @@ -722,7 +699,7 @@ }) $effect(() => { - const appSelection = selectedRunnable + const appSelection: AppSelection = selectedRunnable ? { type: 'backend' as const, backendKey: selectedRunnable } : selectedDocument ? { type: 'frontend' as const, frontendPath: selectedDocument }