diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 82dcbc2dcd..8428f568a1 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -36,6 +36,7 @@ import ContextUsageIndicator from './ContextUsageIndicator.svelte' import AIChatModelSettings from './AIChatModelSettings.svelte' import AssistantSettingsModal from './AssistantSettingsModal.svelte' + import AgentContextSummary from './contextSummary/AgentContextSummary.svelte' import { SkillsMenu } from './skills/skillsMenu.svelte' import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte' import ChatMode from './ChatMode.svelte' @@ -1262,4 +1263,18 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/if} + + {#if messages.length === 0 && chatHost.mode === AIMode.GLOBAL && chatHost.isSessionChat} +
+ +
+ assistantSettings?.open(section)} /> +
+
+ {/if} diff --git a/frontend/src/lib/components/copilot/chat/agentContext.ts b/frontend/src/lib/components/copilot/chat/agentContext.ts index 99b334bcd5..6f8c4e3246 100644 --- a/frontend/src/lib/components/copilot/chat/agentContext.ts +++ b/frontend/src/lib/components/copilot/chat/agentContext.ts @@ -10,6 +10,9 @@ export type ToolSummary = { * `SchemaViewer` reads it to mark the rows and renders nothing without it, and a * tool whose arguments are all optional legitimately omits it. */ parameters: Record + /** The tool reads and never changes anything, which is what plan mode admits. + * `planModeSafe` fails closed there, so an untagged tool counts as a write here too. */ + readOnly: boolean } /** Tool definitions as the modal lists them: name-sorted, so a list of dozens is @@ -19,7 +22,8 @@ export function summarizeTools(tools: readonly Tool[]): ToolSummary[] { .map((t) => ({ name: t.def.function.name, description: t.def.function.description ?? '', - parameters: { required: [], ...(t.def.function.parameters ?? {}) } + parameters: { required: [], ...(t.def.function.parameters ?? {}) }, + readOnly: t.planModeSafe === true })) .sort((a, b) => a.name.localeCompare(b.name)) } diff --git a/frontend/src/lib/components/copilot/chat/contextSummary/AgentContextSummary.svelte b/frontend/src/lib/components/copilot/chat/contextSummary/AgentContextSummary.svelte new file mode 100644 index 0000000000..84c85c3a4a --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/contextSummary/AgentContextSummary.svelte @@ -0,0 +1,154 @@ + + +{#await listsPromise then lists} + +{/await} diff --git a/frontend/src/lib/components/copilot/chat/contextSummary/ContextSectionDetail.svelte b/frontend/src/lib/components/copilot/chat/contextSummary/ContextSectionDetail.svelte new file mode 100644 index 0000000000..99108e45a8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/contextSummary/ContextSectionDetail.svelte @@ -0,0 +1,152 @@ + + +{#snippet toolChips(tools: ContextTool[])} +
+ {#each tools as tool (tool.name)} + {tool.name} + {/each} +
+{/snippet} + +{#snippet scope()} + Applies to all your sessions in {ctx.workspace} +{/snippet} + +{#snippet footer()} +
+ +
+{/snippet} + +
+ {#if summary.empty} +
+ {summary.emptyText} + {#if summary.emptyAction} + + {/if} +
+ {:else if section === 'tools'} + {#each [{ key: 'read', label: 'Read', tools: ctx.tools.filter((t) => t.readOnly) }, { key: 'write', label: 'Write', tools: ctx.tools.filter((t) => !t.readOnly) }] as group (group.key)} + {#if group.tools.length} +
+ {group.label} · {group.tools.length} + {@render toolChips(group.tools)} +
+ {/if} + {/each} + {@render footer()} + {:else if section === 'skills'} + {@render scope()} +
+ {#each ctx.skills as skill (skill.path)} +
+
+ {skill.name} + {#if skill.description} + {skill.description} + {/if} +
+ actions.onToggleSkill(skill.path, e.detail)} + /> +
+ {/each} +
+ {@render footer()} + {:else if section === 'mcp'} + {@render scope()} +
+ {#each ctx.mcpServers as server (server.path)} +
+
+ {server.name} + {#if server.description} + {server.description} + {/if} +
+ actions.onToggleMcp(server.path, e.detail)} + /> +
+ {/each} +
+ {@render footer()} + {:else if section === 'instructions'} + {#each [{ key: 'workspace', label: 'Workspace', text: ctx.instructions.workspace }, { key: 'user', label: 'Personal', text: ctx.instructions.user }] as block (block.key)} +
+ {block.label} + {#if block.text?.trim()} +

{block.text.trim()}

+ {#if block.text.trim().split('\n').length > 3 || block.text.length > 240} + + {/if} + {:else} + None + {/if} +
+ {/each} +
+ +
+ {/if} +
diff --git a/frontend/src/lib/components/copilot/chat/contextSummary/ContextSummaryRows.svelte b/frontend/src/lib/components/copilot/chat/contextSummary/ContextSummaryRows.svelte new file mode 100644 index 0000000000..5546047c38 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/contextSummary/ContextSummaryRows.svelte @@ -0,0 +1,57 @@ + + +
+ Context + {#each sections as s (s.id)} + {@const Icon = CONTEXT_SECTION_ICONS[s.id]} + + {#if open[s.id]} +
+ +
+ {/if} + {/each} +
diff --git a/frontend/src/lib/components/copilot/chat/contextSummary/contextSummary.ts b/frontend/src/lib/components/copilot/chat/contextSummary/contextSummary.ts new file mode 100644 index 0000000000..7cba3a5966 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/contextSummary/contextSummary.ts @@ -0,0 +1,116 @@ +export type ContextSectionId = 'tools' | 'skills' | 'instructions' | 'mcp' + +export type ContextTool = { name: string; description: string; readOnly: boolean } +export type ContextSkill = { path: string; name: string; description: string; enabled: boolean } +export type ContextMcpServer = { + path: string + name: string + description?: string + enabled: boolean +} +/** What a session's agent is given before the first message. `tools` is every tool + * definition the chat loop sends, the MCP bridge tools included — a server's own tools + * are discovered at call time and are not part of the prompt. */ +export type AgentContext = { + workspace: string + tools: ContextTool[] + skills: ContextSkill[] + mcpServers: ContextMcpServer[] + instructions: { workspace?: string; user?: string } +} + +export type ContextSummaryActions = { + onToggleSkill: (path: string, enabled: boolean) => void + onToggleMcp: (path: string, enabled: boolean) => void + /** Opens the assistant settings modal on that section. */ + onManage: (section: ContextSectionId) => void +} + +export type ContextSectionSummary = { + id: ContextSectionId + label: string + /** Undefined for Instructions, which has no count. */ + count: string | undefined + /** Names of what reaches the agent, in display order. */ + names: string[] + empty: boolean + /** Shown when the section is empty, or when everything in it is turned off. */ + emptyText: string + /** Label of the action that opens the settings modal on an empty section. */ + emptyAction?: string +} + +export const CONTEXT_SECTION_ORDER: ContextSectionId[] = [ + 'tools', + 'skills', + 'instructions', + 'mcp' +] + +function partCount(on: number, total: number): string { + return on === total ? `${total}` : `${on} of ${total}` +} + +export function summarizeContextSection( + ctx: AgentContext, + id: ContextSectionId +): ContextSectionSummary { + switch (id) { + case 'tools': { + const read = ctx.tools.filter((t) => t.readOnly).length + return { + id, + label: 'Tools', + count: `${ctx.tools.length}`, + // The split rather than the names: which tools can change something is the one + // thing worth reading off a collapsed row. + names: ctx.tools.length ? [`${read} read`, `${ctx.tools.length - read} write`] : [], + empty: ctx.tools.length === 0, + emptyText: 'No tools' + } + } + case 'skills': { + const on = ctx.skills.filter((s) => s.enabled) + return { + id, + label: 'Skills', + count: ctx.skills.length ? partCount(on.length, ctx.skills.length) : '0', + names: on.map((s) => s.name), + empty: ctx.skills.length === 0, + emptyText: ctx.skills.length ? 'All turned off' : 'No skills', + emptyAction: 'Add skill' + } + } + case 'instructions': { + const names = [ + ctx.instructions.workspace?.trim() ? 'workspace' : undefined, + ctx.instructions.user?.trim() ? 'personal' : undefined + ].filter((n) => n !== undefined) + return { + id, + label: 'Instructions', + count: undefined, + names, + empty: names.length === 0, + emptyText: 'No instructions', + emptyAction: 'Write instructions' + } + } + case 'mcp': { + const on = ctx.mcpServers.filter((s) => s.enabled) + return { + id, + label: 'MCP', + count: ctx.mcpServers.length ? partCount(on.length, ctx.mcpServers.length) : '0', + names: on.map((s) => s.name), + empty: ctx.mcpServers.length === 0, + emptyText: ctx.mcpServers.length ? 'All turned off' : 'No servers', + emptyAction: 'Connect' + } + } + } +} + +export function summarizeContext(ctx: AgentContext): ContextSectionSummary[] { + return CONTEXT_SECTION_ORDER.map((id) => summarizeContextSection(ctx, id)) +} diff --git a/frontend/src/lib/components/copilot/chat/contextSummary/sectionIcons.ts b/frontend/src/lib/components/copilot/chat/contextSummary/sectionIcons.ts new file mode 100644 index 0000000000..6c767d8ce2 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/contextSummary/sectionIcons.ts @@ -0,0 +1,10 @@ +import { BookOpen, Boxes, Plug, ScrollText } from 'lucide-svelte' +import type { ContextSectionId } from './contextSummary' + +/** The settings modal's section icons, so both surfaces read as the same thing. */ +export const CONTEXT_SECTION_ICONS = { + tools: Boxes, + skills: BookOpen, + instructions: ScrollText, + mcp: Plug +} satisfies Record diff --git a/frontend/src/routes/(root)/(logged)/dev/agent-context/+page.svelte b/frontend/src/routes/(root)/(logged)/dev/agent-context/+page.svelte new file mode 100644 index 0000000000..7218591536 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/dev/agent-context/+page.svelte @@ -0,0 +1,77 @@ + + +{#if import.meta.env.DEV} +
+
+
+

Agent context summary

+

+ Dev-only mock of what an empty session chat shows about its agent's context. +

+
+ + {#snippet children({ item })} + {#each MOCK_CONTEXTS as m (m.id)} + + {/each} + {/snippet} + + +
+
Untitled session
+ +
+
+ +
+
+
+
+ Ask anything… + + +
+
+
+
+
+{/if} diff --git a/frontend/src/routes/(root)/(logged)/dev/agent-context/mockContexts.ts b/frontend/src/routes/(root)/(logged)/dev/agent-context/mockContexts.ts new file mode 100644 index 0000000000..2ad6d56a0f --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/dev/agent-context/mockContexts.ts @@ -0,0 +1,187 @@ +import type { + AgentContext, + ContextTool +} from '$lib/components/copilot/chat/contextSummary/contextSummary' + +const BUILTIN_TOOL_NAMES = [ + 'askUserQuestion', + 'cancel_job', + 'close_page', + 'create_artifact', + 'create_folder', + 'delete_workspace_item', + 'deploy_workspace_item', + 'diff', + 'discard_local_draft', + 'edit_script', + 'get_instructions', + 'get_preview_status', + 'get_run', + 'get_schedule_schema', + 'get_trigger_schema', + 'list_artifacts', + 'list_runs', + 'list_workers', + 'list_workspace_items', + 'open_page', + 'open_preview', + 'patch_flow_json', + 'read_artifact', + 'read_dom', + 'read_flow_module_code', + 'read_skill', + 'read_workspace_item', + 'rebase_draft', + 'run_flow', + 'run_script', + 'search_dom', + 'search_resource_types', + 'set_flow_module_code', + 'take_screenshot', + 'test_run_flow', + 'test_run_script', + 'test_run_step', + 'update_user_instructions', + 'write_resource', + 'write_schedule', + 'write_trigger', + 'write_variable' +] + +// Read vs write the way the real summary decides it: a tool that only reads is the one +// plan mode admits. +const WRITE_PREFIXES = ['write_', 'delete_', 'deploy_', 'patch_', 'init_', 'test_run_', 'run_'] + +function tools(names: string[]): ContextTool[] { + return names.map((name) => ({ + name, + description: `Mock description of ${name}.`, + readOnly: !WRITE_PREFIXES.some((p) => name.startsWith(p)) + })) +} + +const empty: AgentContext = { + workspace: 'admins', + tools: tools(BUILTIN_TOOL_NAMES.slice(0, 38)), + skills: [], + mcpServers: [], + instructions: {} +} + +const typical: AgentContext = { + workspace: 'acme', + tools: tools(BUILTIN_TOOL_NAMES.slice(0, 38)), + skills: [ + { + path: 'f/ai/sql_style', + name: 'sql_style', + description: 'How we write SQL against the warehouse', + enabled: true + }, + { + path: 'f/ai/pr_conventions', + name: 'pr_conventions', + description: 'Branch naming and PR description format', + enabled: false + }, + { + path: 'f/ai/deploy_checklist', + name: 'deploy_checklist', + description: 'Checks to run before deploying a flow to prod', + enabled: true + } + ], + mcpServers: [ + { + path: 'u/admin/linear', + name: 'Linear', + enabled: true + }, + { + path: 'u/admin/notion', + name: 'Notion', + enabled: false + } + ], + instructions: { + workspace: + 'Scripts go under f//. Always use the postgres resource f/data/warehouse for analytics queries.', + user: 'Answer briefly. Prefer Python over TypeScript.' + } +} + +const heavy: AgentContext = { + workspace: 'platform-prod', + tools: tools([ + ...BUILTIN_TOOL_NAMES, + 'write_app_file', + 'read_app_file', + 'patch_app_file', + 'delete_app_file', + 'init_app', + 'search_app', + 'list_app_runs', + 'get_app_runtime_logs', + 'write_app_runnable', + 'delete_app_runnable', + 'test_run_app_runnable', + 'update_artifact', + 'audit_logs', + 'workspace_settings', + 'resources', + 'variables', + 'schedules', + 'triggers' + ]), + skills: Array.from({ length: 12 }, (_, i) => ({ + path: `f/ai/skill_${i + 1}`, + name: [ + 'sql_style', + 'pr_conventions', + 'deploy_checklist', + 'incident_runbook', + 'naming_rules', + 'python_deps', + 'retry_policy', + 'slack_alerts', + 'datatable_migrations', + 'hub_usage', + 'secrets_handling', + 'flow_patterns' + ][i], + description: 'Mock skill description that is long enough to be truncated in a narrow column', + enabled: i % 4 !== 3 + })), + mcpServers: [ + { + path: 'f/mcp/linear', + name: 'Linear', + enabled: true + }, + { + path: 'f/mcp/github', + name: 'GitHub', + enabled: true + }, + { path: 'f/mcp/notion', name: 'Notion', enabled: false }, + { path: 'f/mcp/sentry', name: 'Sentry', enabled: true }, + { path: 'f/mcp/datadog', name: 'Datadog', enabled: false } + ], + instructions: { + workspace: [ + 'This is the production platform workspace.', + 'Never deploy directly: stage every change in a fork and ask for review.', + 'Scripts go under f//, one folder per team.', + 'Use f/data/warehouse for analytics and f/data/app_db for application data.', + 'Every flow must set an error handler pointing to f/ops/alert_oncall.', + 'Schedules run in UTC.' + ].join('\n'), + user: 'Answer briefly.\nPrefer Python.\nWhen unsure about a resource, ask before creating one.\nI am on the data team.' + } +} + +export const MOCK_CONTEXTS: { id: string; label: string; ctx: AgentContext }[] = [ + { id: 'empty', label: 'Fresh workspace', ctx: empty }, + { id: 'typical', label: 'Typical', ctx: typical }, + { id: 'heavy', label: 'Heavy', ctx: heavy } +]