refactor: clean up app chat selection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-04-13 12:32:08 +02:00
co-authored by Claude Opus 4.5
parent 31975aaa55
commit f31c68e8be
7 changed files with 103 additions and 110 deletions
@@ -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()
})
</script>
@@ -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<SelectedContext, 'type' | 'frontendPath' | 'backendKey'> | 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) => {
@@ -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<SelectedContext, 'type' | 'frontendPath' | 'backendKey'> | 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) {
@@ -3,7 +3,7 @@ import type {
AppFiles,
BackendRunnable,
LintResult,
SelectedContext
AppTransientContext
} from '../../app/core'
/**
@@ -30,7 +30,10 @@ export function createAppEvalHelpers(
let frontend: Record<string, string> = { ...initialFrontend }
let backend: Record<string, BackendRunnable> = { ...initialBackend }
let snapshotId = 0
const snapshots: Map<number, { frontend: Record<string, string>; backend: Record<string, BackendRunnable> }> = new Map()
const snapshots: Map<
number,
{ frontend: Record<string, string>; backend: Record<string, BackendRunnable> }
> = 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
}
}
@@ -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({
@@ -82,18 +82,22 @@ export interface InspectorElementInfo {
styles: Record<string, string>
}
/** 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<AppAIChatHelpers>[] => [
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`
@@ -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 }