feat(ai-chat): show the agent context in an empty session

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-18 16:31:34 +02:00
co-authored by Claude Opus 5
parent 6e1ef93f32
commit 164c701d90
9 changed files with 773 additions and 1 deletions
@@ -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. -->
</div>
{/if}
</div>
<!-- After the composer, so an empty session puts the input at the top and what the
agent is carrying underneath it. -->
{#if messages.length === 0 && chatHost.mode === AIMode.GLOBAL && chatHost.isSessionChat}
<div class="flex-1 min-h-0 overflow-y-auto">
<!-- Same max width and gutter as the composer above, so the two line up. -->
<div
class={wideLayout
? 'w-full max-w-3xl mx-auto px-6 pt-8 pb-2'
: 'w-full max-w-2xl mx-auto px-2 pt-8 pb-2'}
>
<AgentContextSummary onManage={(section) => assistantSettings?.open(section)} />
</div>
</div>
{/if}
</div>
@@ -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<string, any>
/** 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<any>[]): 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))
}
@@ -0,0 +1,154 @@
<script lang="ts">
// What the session's agent is carrying, shown while the session has no messages.
// Everything here is read from the same places the system prompt is built from.
import { workspaceStore } from '$lib/stores'
import { ResourceService } from '$lib/gen'
import { getCustomPromptParts } from '$lib/aiStore'
import { sendUserToast } from '$lib/toast'
import { isMcpEnabled, setMcpEnabled } from '$lib/components/mcp/enabledServers'
import {
isOwnOrSharedMcpPath,
MCP_LIST_PER_PAGE,
mcpViewer
} from '$lib/components/mcp/ownServers'
import { AIMode } from '../AIChatManager.svelte'
import { getAiChatManager } from '../aiChatManagerContext'
import { summarizeTools } from '../agentContext'
import { isSkillEnabled, setSkillEnabled } from '../skills/enabledSkills'
import { listSkillResources } from '../skills/skillResources'
import ContextSummaryRows from './ContextSummaryRows.svelte'
import type {
AgentContext,
ContextMcpServer,
ContextSectionId,
ContextSkill,
ContextSummaryActions
} from './contextSummary'
let { onManage }: { onManage: (section: ContextSectionId) => void } = $props()
const aiChatManager = getAiChatManager()
// The workspace every list here is read under — a session operates on its own
// (possibly forked) workspace without switching `workspaceStore`.
let ws = $derived(aiChatManager.operatingWorkspace ?? $workspaceStore ?? '')
// A session whose fork is still staged has no workspace of its own yet, so `ws` is
// the PARENT: a switch flipped now would be stored under it and stop applying the
// moment the first send commits the fork.
let toggleBlockedReason = $derived(
aiChatManager.sessionContextResolver?.()?.pendingForkOf
? 'This session creates its workspace on the first message. Send one before changing this.'
: undefined
)
// The full lists, disabled entries included — `aiChatManager` keeps only what is on,
// and a switch needs the rows it can turn back on. One promise per workspace, so a
// workspace change replaces it rather than racing it.
let listsPromise = $derived(loadLists(ws))
// Flips applied since the lists were read, so a switch lands without a reload. One map
// per kind: a skill and a server can hold the same resource path.
let skillOverrides = $state<Record<string, boolean>>({})
let mcpOverrides = $state<Record<string, boolean>>({})
async function loadLists(
workspace: string
): Promise<{ skills: ContextSkill[]; mcpServers: ContextMcpServer[] }> {
if (!workspace) return { skills: [], mcpServers: [] }
const [skills, mcpServers] = await Promise.all([
loadSkills(workspace),
loadMcpServers(workspace)
])
return { skills, mcpServers }
}
async function loadSkills(workspace: string): Promise<ContextSkill[]> {
try {
return (await listSkillResources(workspace)).skills.map(({ path, name, description }) => ({
path,
name,
description: description ?? '',
enabled: isSkillEnabled(workspace, path)
}))
} catch (e) {
console.error('Failed to load AI skills', e)
return []
}
}
async function loadMcpServers(workspace: string): Promise<ContextMcpServer[]> {
try {
const [resources, viewer] = await Promise.all([
ResourceService.listResource({
workspace,
resourceType: 'mcp',
perPage: MCP_LIST_PER_PAGE
}),
mcpViewer(workspace)
])
return resources
.filter((r) => isOwnOrSharedMcpPath(r.path, r.extra_perms, viewer))
.map((r) => ({
path: r.path,
name: r.path,
description: r.description,
enabled: isMcpEnabled(workspace, r.path)
}))
} catch (e) {
console.error('Failed to load MCP connections', e)
return []
}
}
function applyOverrides<T extends { path: string; enabled: boolean }>(
rows: T[],
overrides: Record<string, boolean>
): T[] {
return rows.map((row) => ({ ...row, enabled: overrides[row.path] ?? row.enabled }))
}
function buildContext(lists: {
skills: ContextSkill[]
mcpServers: ContextMcpServer[]
}): AgentContext {
return {
workspace: ws,
// Exactly what the chat loop sends: plan mode's transition tool is registered
// alongside `tools` there, so listing only `tools` would under-report.
tools: summarizeTools([...aiChatManager.tools, ...aiChatManager.planMode.tools]).map(
({ name, description, readOnly }) => ({ name, description, readOnly })
),
skills: applyOverrides(lists.skills, skillOverrides),
mcpServers: applyOverrides(lists.mcpServers, mcpOverrides),
instructions: getCustomPromptParts(AIMode.GLOBAL)
}
}
const actions: ContextSummaryActions = {
onToggleSkill: async (path, enabled) => {
if (!setSkillEnabled(ws, path, enabled)) {
sendUserToast('Could not save this choice for this account.', true)
return
}
skillOverrides[path] = enabled
// The prompt lists exactly the skills that are on, so it has to be rebuilt
// before the next message rather than on the next mode change.
await aiChatManager.refreshGlobalSkills(ws)
},
onToggleMcp: async (path, enabled) => {
const target = ws
if (!setMcpEnabled(target, path, enabled)) {
sendUserToast('Could not save the selection for this account.', true)
return
}
mcpOverrides[path] = enabled
if (target !== ws) return
await aiChatManager.refreshMcpServers(target)
},
onManage
}
</script>
{#await listsPromise then lists}
<ContextSummaryRows ctx={buildContext(lists)} {actions} {toggleBlockedReason} />
{/await}
@@ -0,0 +1,152 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import {
summarizeContextSection,
type AgentContext,
type ContextSectionId,
type ContextTool,
type ContextSummaryActions
} from './contextSummary'
let {
ctx,
section,
actions,
toggleBlockedReason
}: {
ctx: AgentContext
section: ContextSectionId
actions: ContextSummaryActions
/** Why the switches are disabled, when they are — a session whose fork is still
* staged would store the choice under the parent workspace. */
toggleBlockedReason?: string
} = $props()
let summary = $derived(summarizeContextSection(ctx, section))
let expandedInstructions = $state<Record<string, boolean>>({})
</script>
{#snippet toolChips(tools: ContextTool[])}
<div class="flex flex-wrap gap-1">
{#each tools as tool (tool.name)}
<span
class="font-mono text-2xs px-1.5 py-0.5 rounded bg-surface-secondary text-secondary"
title={tool.description}>{tool.name}</span
>
{/each}
</div>
{/snippet}
{#snippet scope()}
<span class="text-2xs text-hint">Applies to all your sessions in {ctx.workspace}</span>
{/snippet}
{#snippet footer()}
<div class="flex justify-end">
<Button variant="subtle" unifiedSize="xs" onclick={() => actions.onManage(section)}>
Manage
</Button>
</div>
{/snippet}
<div class="flex flex-col gap-2">
{#if summary.empty}
<div class="flex items-center gap-2">
<span class="text-2xs text-hint">{summary.emptyText}</span>
{#if summary.emptyAction}
<Button variant="default" unifiedSize="xs" onclick={() => actions.onManage(section)}>
{summary.emptyAction}
</Button>
{/if}
</div>
{: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}
<div class="flex flex-col gap-1">
<span class="text-2xs font-semibold text-emphasis"
>{group.label}<span class="font-normal text-hint"> · {group.tools.length}</span></span
>
{@render toolChips(group.tools)}
</div>
{/if}
{/each}
{@render footer()}
{:else if section === 'skills'}
{@render scope()}
<div class="flex flex-col">
{#each ctx.skills as skill (skill.path)}
<div class="flex items-center gap-3 py-1">
<div class="flex flex-col min-w-0 grow">
<span class="text-xs {skill.enabled ? 'text-primary' : 'text-disabled'} truncate"
>{skill.name}</span
>
{#if skill.description}
<span class="text-2xs text-secondary truncate">{skill.description}</span>
{/if}
</div>
<Toggle
size="2xs"
disabled={toggleBlockedReason !== undefined}
options={{ title: toggleBlockedReason }}
checked={skill.enabled}
on:change={(e) => actions.onToggleSkill(skill.path, e.detail)}
/>
</div>
{/each}
</div>
{@render footer()}
{:else if section === 'mcp'}
{@render scope()}
<div class="flex flex-col">
{#each ctx.mcpServers as server (server.path)}
<div class="flex items-center gap-3 py-1">
<div class="flex flex-col min-w-0 grow">
<span class="text-xs {server.enabled ? 'text-primary' : 'text-disabled'} truncate"
>{server.name}</span
>
{#if server.description}
<span class="text-2xs text-secondary truncate">{server.description}</span>
{/if}
</div>
<Toggle
size="2xs"
disabled={toggleBlockedReason !== undefined}
options={{ title: toggleBlockedReason }}
checked={server.enabled}
on:change={(e) => actions.onToggleMcp(server.path, e.detail)}
/>
</div>
{/each}
</div>
{@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)}
<div class="flex flex-col gap-0.5">
<span class="text-2xs font-semibold text-emphasis">{block.label}</span>
{#if block.text?.trim()}
<p
class="text-2xs text-secondary whitespace-pre-wrap {expandedInstructions[block.key]
? ''
: 'line-clamp-3'}">{block.text.trim()}</p
>
{#if block.text.trim().split('\n').length > 3 || block.text.length > 240}
<button
type="button"
class="self-start text-2xs text-accent hover:underline"
onclick={() => (expandedInstructions[block.key] = !expandedInstructions[block.key])}
>{expandedInstructions[block.key] ? 'Show less' : 'Show more'}</button
>
{/if}
{:else}
<span class="text-2xs text-hint">None</span>
{/if}
</div>
{/each}
<div class="flex justify-end">
<Button variant="subtle" unifiedSize="xs" onclick={() => actions.onManage(section)}>
Edit
</Button>
</div>
{/if}
</div>
@@ -0,0 +1,57 @@
<script lang="ts">
import { ChevronRight } from 'lucide-svelte'
import { slide } from 'svelte/transition'
import ContextSectionDetail from './ContextSectionDetail.svelte'
import {
summarizeContext,
type AgentContext,
type ContextSectionId,
type ContextSummaryActions
} from './contextSummary'
import { CONTEXT_SECTION_ICONS } from './sectionIcons'
let {
ctx,
actions,
toggleBlockedReason
}: {
ctx: AgentContext
actions: ContextSummaryActions
toggleBlockedReason?: string
} = $props()
let sections = $derived(summarizeContext(ctx))
// Each row opens and closes on its own: comparing two sections means having both open.
let open = $state<Partial<Record<ContextSectionId, boolean>>>({})
</script>
<div class="flex flex-col w-full">
<span class="text-2xs text-secondary px-2 pb-1">Context</span>
{#each sections as s (s.id)}
{@const Icon = CONTEXT_SECTION_ICONS[s.id]}
<button
type="button"
class="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-surface-hover text-left min-w-0"
aria-expanded={open[s.id] ?? false}
onclick={() => (open[s.id] = !open[s.id])}
>
<ChevronRight
size={12}
class="shrink-0 text-hint transition-transform {open[s.id] ? 'rotate-90' : ''}"
/>
<Icon size={12} class="shrink-0 {s.empty ? 'text-hint' : 'text-tertiary'}" />
<span class="text-xs w-20 shrink-0 {s.empty ? 'text-hint' : 'text-emphasis'}">{s.label}</span>
<span class="text-xs w-12 shrink-0 tabular-nums {s.empty ? 'text-hint' : 'text-secondary'}"
>{s.count ?? ''}</span
>
<span class="text-2xs text-hint truncate min-w-0">
{s.names.length ? s.names.join(', ') : s.emptyText}
</span>
</button>
{#if open[s.id]}
<div class="pl-10 pr-2 pt-1 pb-3" transition:slide|local={{ duration: 150 }}>
<ContextSectionDetail {ctx} section={s.id} {actions} {toggleBlockedReason} />
</div>
{/if}
{/each}
</div>
@@ -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))
}
@@ -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<ContextSectionId, unknown>
@@ -0,0 +1,77 @@
<script lang="ts">
// Dev-only design page for the context summary an empty session chat shows. Not
// linked anywhere; open at /dev/agent-context. Mock data only, so the shapes a real
// workspace rarely has (nothing configured, a dozen skills) can be looked at.
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import ContextSummaryRows from '$lib/components/copilot/chat/contextSummary/ContextSummaryRows.svelte'
import type {
AgentContext,
ContextSummaryActions
} from '$lib/components/copilot/chat/contextSummary/contextSummary'
import { sendUserToast } from '$lib/toast'
import { ArrowUp, Paperclip } from 'lucide-svelte'
import { MOCK_CONTEXTS } from './mockContexts'
let scenario = $state(MOCK_CONTEXTS[1].id)
let ctx = $state<AgentContext>(structuredClone(MOCK_CONTEXTS[1].ctx))
function selectScenario(id: string) {
const found = MOCK_CONTEXTS.find((m) => m.id === id)
if (found) ctx = structuredClone(found.ctx)
}
const actions: ContextSummaryActions = {
onToggleSkill: (path, enabled) => {
const skill = ctx.skills.find((s) => s.path === path)
if (skill) skill.enabled = enabled
},
onToggleMcp: (path, enabled) => {
const server = ctx.mcpServers.find((s) => s.path === path)
if (server) server.enabled = enabled
},
onManage: (section) => sendUserToast(`Would open assistant settings on "${section}"`)
}
</script>
{#if import.meta.env.DEV}
<div class="h-full overflow-auto p-6 bg-surface">
<div class="max-w-[720px] mx-auto flex flex-col gap-4">
<div class="flex flex-col gap-1">
<h1 class="text-lg font-semibold text-emphasis">Agent context summary</h1>
<p class="text-xs text-secondary">
Dev-only mock of what an empty session chat shows about its agent's context.
</p>
</div>
<ToggleButtonGroup bind:selected={scenario} onSelected={selectScenario} noWFull>
{#snippet children({ item })}
{#each MOCK_CONTEXTS as m (m.id)}
<ToggleButton value={m.id} label={m.label} {item} small />
{/each}
{/snippet}
</ToggleButtonGroup>
<div
class="h-[560px] rounded-md border border-light bg-surface-primary flex flex-col overflow-hidden"
>
<div class="px-4 py-2 text-sm font-semibold text-hint shrink-0">Untitled session</div>
<!-- Auto margins rather than justify-*: justify clips content taller than the
scroller. -->
<div class="flex-1 min-h-0 overflow-y-auto px-4 flex flex-col">
<div class="w-full max-w-xl mx-auto py-2 my-auto">
<ContextSummaryRows {ctx} {actions} />
</div>
</div>
<div class="px-4 pb-3 pt-1 shrink-0">
<div
class="max-w-xl mx-auto rounded-md border border-light bg-surface-input px-3 py-2 flex items-center gap-2"
>
<span class="text-xs text-hint grow">Ask anything…</span>
<Paperclip size={14} class="text-hint" />
<ArrowUp size={14} class="text-hint" />
</div>
</div>
</div>
</div>
</div>
{/if}
@@ -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/<team>/. 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/<team>/, 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 }
]