feat: organize AI session context by folder

This commit is contained in:
Guilhem Lemouel
2026-09-22 11:08:38 +02:00
parent d4063f357b
commit e721e6663d
15 changed files with 734 additions and 815 deletions
@@ -38,7 +38,9 @@
import AIChatModelSettings from './AIChatModelSettings.svelte'
import ScrollFade from '$lib/components/ScrollFade.svelte'
import AssistantSettingsModal from './AssistantSettingsModal.svelte'
import AgentContextSummary from './contextSummary/AgentContextSummary.svelte'
import WorkingFolders from './contextSummary/WorkingFolders.svelte'
import FolderAccessTree from './contextSummary/FolderAccessTree.svelte'
import { FolderAccessState } from './contextSummary/folderAccessState.svelte'
import { SkillsMenu } from './skills/skillsMenu.svelte'
import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte'
import ChatMode from './ChatMode.svelte'
@@ -69,6 +71,7 @@
import { sendUserToast } from '$lib/toast'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { copilotInfo } from '$lib/aiStore'
import { workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
const MAX_YOLO_TOOLTIP_TOOLS = 8
@@ -231,6 +234,9 @@
let aiChatInput: AIChatInput | undefined = $state()
let assistantSettings: AssistantSettingsModal | undefined = $state()
const folderAccessState = new FolderAccessState(
() => aiChatManager.operatingWorkspace ?? $workspaceStore ?? ''
)
// The "+" menu's skill and MCP rows: enough state to check and flip one, with
// everything else about them behind the assistant settings modal.
const skillsMenu = new SkillsMenu(aiChatManager, () => assistantSettings?.open('skills'))
@@ -585,6 +591,9 @@
? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}`
: `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}`
)
const showEmptySessionContext = $derived(
messages.length === 0 && chatHost.mode === AIMode.GLOBAL && chatHost.isSessionChat
)
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
@@ -687,7 +696,11 @@
<!-- tabindex="-1": clicks on non-focusable chat content must move focus into
the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div
class="flex flex-col h-full relative outline-none"
class={twMerge(
'flex flex-col h-full relative outline-none',
showEmptySessionContext ? 'overflow-y-auto scrollbar-subtle' : ''
)}
style={showEmptySessionContext ? 'scrollbar-gutter: stable;' : undefined}
tabindex="-1"
bind:this={panelEl}
ondragenter={onPanelDragEnter}
@@ -815,6 +828,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div class="flex-1 min-h-0 relative">
<div
class="absolute inset-0 overflow-y-scroll pt-2 scrollbar-subtle"
style="scrollbar-gutter: stable;"
bind:this={scrollElement}
onscroll={onScroll}
>
@@ -886,7 +900,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<!-- Same horizontal padding as the transcript above: the composer's edges line up with
the messages rather than sitting closer to the panel edge. -->
<div class="relative {columnClass} pb-2">
<div
class="{showEmptySessionContext
? 'sticky top-0 z-20 shrink-0 bg-surface'
: 'relative'} {columnClass} pb-2"
>
{#if showFlowPendingActionControls}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
<Button
@@ -1222,7 +1240,14 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
{@render footerSettings?.()}
{#if chatHost.mode === AIMode.GLOBAL}
<AssistantSettingsModal bind:this={assistantSettings} />
<AssistantSettingsModal bind:this={assistantSettings}>
{#snippet workingFolders(onChange, disabled)}
<WorkingFolders state={folderAccessState} {onChange} {disabled} />
{/snippet}
{#snippet folderSelection()}
<FolderAccessTree state={folderAccessState} title="Select folders" />
{/snippet}
</AssistantSettingsModal>
{/if}
{#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
@@ -1291,15 +1316,15 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</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">
{#if showEmptySessionContext}
<div class="shrink-0 min-h-0">
<!-- 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)} />
<FolderAccessTree state={folderAccessState} />
</div>
</div>
{/if}
@@ -14,7 +14,17 @@ callers that already know which section they mean, such as the "+" menu's Manage
</script>
<script lang="ts">
import { BookOpen, Boxes, Paperclip, Plug, ScrollText, SlidersHorizontal } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import {
ArrowLeft,
BookOpen,
Boxes,
Paperclip,
Plug,
ScrollText,
SlidersHorizontal
} from 'lucide-svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte'
@@ -29,6 +39,14 @@ callers that already know which section they mean, such as the "+" menu's Manage
import AssistantMcpSection from './AssistantMcpSection.svelte'
import AssistantFilesSection from './AssistantFilesSection.svelte'
let {
workingFolders,
folderSelection
}: {
workingFolders?: Snippet<[onChange: () => void, disabled: boolean]>
folderSelection?: Snippet
} = $props()
let editingFolders = $state(false)
const aiChatManager = getAiChatManager()
let isOpen = $state(false)
@@ -74,6 +92,12 @@ callers that already know which section they mean, such as the "+" menu's Manage
* user was brought here to see. */
function onKeydown(event: KeyboardEvent) {
if (!isOpen || event.key !== 'Escape') return
if (editingFolders) {
event.preventDefault()
event.stopImmediatePropagation()
editingFolders = false
return
}
const blocker = blockingSection
if (!blocker || blocker === section) return
event.preventDefault()
@@ -110,6 +134,7 @@ callers that already know which section they mean, such as the "+" menu's Manage
/** Opens on `target`, or back on whichever section was last read. */
export function open(target: AssistantSettingsSection = section) {
editingFolders = false
section = target
isOpen = true
refresh()
@@ -170,69 +195,110 @@ callers that already know which section they mean, such as the "+" menu's Manage
{/snippet}
</Tooltip>
<Modal2
bind:isOpen
title="Assistant settings"
fixedWidth="md"
fixedHeight="lg"
closeOnOutsideClick={!blocksClose}
closeOnEscape={!blocksClose}
>
{#snippet headerLeft()}
<p class="pl-3 pt-1 text-xs text-secondary truncate">
What the assistant can see and use in this session.
</p>
{/snippet}
{#snippet headerLeft()}
<p class="pl-3 pt-1 text-xs text-secondary truncate">
What the assistant can see and use in this session.
</p>
{/snippet}
<div class="w-full flex min-h-0 gap-4">
<div class="w-52 shrink-0 flex flex-col border-r border-border-light pr-3">
<SidebarNavigation
groups={[{ items: sections }]}
selectedId={section}
onNavigate={(id) => select(id as AssistantSettingsSection)}
/>
{#snippet foldersPage()}
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex items-center gap-2">
<Button
variant="subtle"
unifiedSize="sm"
startIcon={{ icon: ArrowLeft }}
onclick={() => (editingFolders = false)}>Back</Button
>
<span class="text-sm text-primary">Working folders</span>
</div>
<div class="min-h-0 grow overflow-y-auto scrollbar-subtle [scrollbar-gutter:stable]">
{@render folderSelection?.()}
</div>
</div>
{/snippet}
{#snippet settingsPage()}
<div class="w-full h-full flex min-h-0 flex-col gap-4">
{@render workingFolders?.(() => (editingFolders = true), blocksClose)}
<div class="w-full flex grow min-h-0 gap-4">
<div class="w-52 shrink-0 flex flex-col border-r border-border-light pr-3">
<SidebarNavigation
groups={[{ items: sections }]}
selectedId={section}
onNavigate={(id) => select(id as AssistantSettingsSection)}
/>
</div>
<div class="grow min-w-0 flex flex-col min-h-0">
<!-- Every section stays mounted while the modal is open: the sidebar badges
<div class="grow min-w-0 flex flex-col min-h-0">
<!-- Every section stays mounted while the modal is open: the sidebar badges
count what each one loaded, so hiding is display-only. Tools, Skills and MCP
own their own scrolling — each is a list and a detail page laid over each
other — so only Instructions scrolls here. -->
<div class="{section === 'tools' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden">
<AssistantToolsSection {tools} active={section === 'tools'} bind:blocksClose={toolsBusy} />
</div>
<!-- Skills owns its own scrolling: its list and its editor are PagedContent pages
<div
class="{section === 'tools' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden"
>
<AssistantToolsSection
{tools}
active={!editingFolders && section === 'tools'}
bind:blocksClose={toolsBusy}
/>
</div>
<!-- Skills owns its own scrolling: its list and its editor are PagedContent pages
laid over each other, and each keeps a scroll position of its own. -->
<div class="{section === 'skills' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden">
<AssistantSkillsSection
{ws}
active={section === 'skills'}
bind:count={skillCount}
bind:blocksClose={skillsBusy}
/>
</div>
<div
class="{section === 'instructions' ? 'block' : 'hidden'} grow min-h-0 overflow-y-auto pr-2"
>
<AssistantInstructionsSection
{ws}
active={section === 'instructions'}
bind:blocksClose={instructionsBusy}
/>
</div>
<!-- Like Skills, MCP owns its own scrolling: its list and its connect form are
<div
class="{section === 'skills' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden"
>
<AssistantSkillsSection
{ws}
active={!editingFolders && section === 'skills'}
bind:count={skillCount}
bind:blocksClose={skillsBusy}
/>
</div>
<div
class="{section === 'instructions'
? 'block'
: 'hidden'} grow min-h-0 overflow-y-auto pr-2"
>
<AssistantInstructionsSection
{ws}
active={!editingFolders && section === 'instructions'}
bind:blocksClose={instructionsBusy}
/>
</div>
<!-- Like Skills, MCP owns its own scrolling: its list and its connect form are
PagedContent pages laid over each other. -->
<div class="{section === 'mcp' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden">
<AssistantMcpSection
{ws}
active={section === 'mcp'}
bind:count={mcpCount}
bind:blocksClose={mcpBusy}
/>
</div>
<div class="{section === 'files' ? 'block' : 'hidden'} grow min-h-0 overflow-y-auto pr-2">
<AssistantFilesSection bind:count={fileCount} bind:blocksClose={filesBusy} />
<div class="{section === 'mcp' ? 'flex' : 'hidden'} grow min-h-0 flex-col overflow-hidden">
<AssistantMcpSection
{ws}
active={!editingFolders && section === 'mcp'}
bind:count={mcpCount}
bind:blocksClose={mcpBusy}
/>
</div>
<div class="{section === 'files' ? 'block' : 'hidden'} grow min-h-0 overflow-y-auto pr-2">
<AssistantFilesSection bind:count={fileCount} bind:blocksClose={filesBusy} />
</div>
</div>
</div>
</div>
{/snippet}
<Modal2
bind:isOpen
title="Assistant settings"
{headerLeft}
fixedWidth="md"
fixedHeight="lg"
closeOnOutsideClick={!blocksClose}
closeOnEscape={!blocksClose && !editingFolders}
>
<PagedContent
class="w-full grow min-h-0"
current={editingFolders ? 'folders' : 'settings'}
pages={[
{ key: 'settings', content: settingsPage },
{ key: 'folders', content: foldersPage }
]}
/>
</Modal2>
@@ -0,0 +1,72 @@
<script lang="ts">
import {
BookOpen,
Code2,
Database,
DollarSign,
ExternalLink,
LayoutDashboard,
Plug,
ScrollText
} from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { ACCESS_ENTRY_KIND_ORDER, type AccessEntryKind, type AccessScope } from './folderAccess'
let { scope, dense = false }: { scope: AccessScope; dense?: boolean } = $props()
const sectionLabels: Record<AccessEntryKind, string> = {
mcp: 'MCP servers',
instruction: 'Instructions',
skill: 'Skills',
script: 'Scripts',
flow: 'Flows',
app: 'Apps',
resource: 'Resources',
variable: 'Variables'
}
const kindIcons: Record<AccessEntryKind, typeof BookOpen | typeof BarsStaggered> = {
mcp: Plug,
instruction: ScrollText,
skill: BookOpen,
script: Code2,
flow: BarsStaggered,
app: LayoutDashboard,
resource: Database,
variable: DollarSign
}
</script>
<div class="flex flex-col">
{#each ACCESS_ENTRY_KIND_ORDER as kind, kindIndex (kind)}
{@const entries = scope.entries.filter((entry) => entry.kind === kind)}
{@const KindIcon = kindIcons[kind]}
{#if entries.length}
<section class={kindIndex === 0 ? undefined : dense ? 'pt-1' : 'pt-2'}>
<div class="flex items-center gap-1 text-2xs font-normal text-hint">
<KindIcon size={12} class="shrink-0" />
<span>{sectionLabels[kind]}</span>
</div>
<div class="flex flex-col pl-5">
{#each entries as item (item.id)}
<a
href={item.path}
onclick={(event) => event.preventDefault()}
title={item.name}
class="group inline-flex min-w-0 items-center gap-1 {dense
? 'py-0.5'
: 'py-1'} text-2xs text-secondary hover:text-emphasis focus-visible:text-emphasis"
>
<span class="truncate group-hover:underline group-focus-visible:underline"
>{item.name}</span
>
<ExternalLink
size={10}
class="shrink-0 text-hint opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
/>
</a>
{/each}
</div>
</section>
{/if}
{/each}
</div>
@@ -1,154 +0,0 @@
<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}
@@ -1,152 +0,0 @@
<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>
@@ -1,57 +0,0 @@
<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,93 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Checkbox from '$lib/components/common/checkbox/Checkbox.svelte'
import { Building, ChevronRight, Folder, UserRound } from 'lucide-svelte'
import AccessManifest from './AccessManifest.svelte'
import { scopeRecap, type AccessScope } from './folderAccess'
import type { FolderAccessState } from './folderAccessState.svelte'
let { state, title = 'Context' }: { state: FolderAccessState; title?: string } = $props()
function scopeIcon(scope: AccessScope) {
if (scope.kind === 'personal') return UserRound
if (scope.kind === 'workspace') return Building
return Folder
}
</script>
<div class="flex flex-col w-full min-w-0">
<div class="flex items-center gap-1 px-2 pb-1">
<span class="text-2xs text-secondary grow min-w-0">{title}</span>
<div class="flex items-center gap-1">
<Button
variant="subtle"
unifiedSize="2xs"
disabled={state.selectedScopes.length === state.scopes.length}
onclick={() => state.setAll(true)}>All</Button
>
<Button
variant="subtle"
unifiedSize="2xs"
disabled={state.selectedScopes.length === 0}
onclick={() => state.setAll(false)}>None</Button
>
</div>
</div>
<div class="flex flex-col">
{#each state.scopes as scope (scope.id)}
{@const Icon = scopeIcon(scope)}
{@const recap = scopeRecap(scope)}
<div
class={state.isSelected(scope.id) ? 'transition-opacity' : 'opacity-60 transition-opacity'}
>
<div
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-2 py-0.5 min-w-0 rounded-md hover:bg-surface-hover"
>
<button
type="button"
class="flex items-center gap-2 text-left min-w-0 w-full rounded-md py-0.5 font-normal"
aria-expanded={state.expanded[scope.id] ?? false}
onclick={() => state.toggleExpanded(scope.id)}
>
<ChevronRight
size={12}
class="shrink-0 text-hint transition-transform {state.expanded[scope.id]
? 'rotate-90'
: ''}"
/>
<Icon
size={12}
class="shrink-0 {state.isSelected(scope.id) ? 'text-tertiary' : 'text-hint'}"
/>
<span
title={scope.name}
class="text-xs font-normal truncate {state.isSelected(scope.id)
? 'text-emphasis'
: 'text-secondary'}">{scope.name}</span
>
<span
title={recap}
class="ml-auto min-w-0 max-w-[65%] truncate text-right text-2xs font-normal text-secondary"
>{recap}</span
>
</button>
<label
aria-label={scope.name}
class="flex items-center justify-end shrink-0 px-2 py-0.5 cursor-pointer"
>
<Checkbox
checked={state.isSelected(scope.id)}
onClick={(event) => state.setSelected(scope.id, event.currentTarget.checked)}
/>
</label>
</div>
{#if state.expanded[scope.id]}
<div class="pl-8 pr-12 pb-1">
<AccessManifest {scope} dense />
</div>
{/if}
</div>
{/each}
</div>
</div>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import type { FolderAccessState } from './folderAccessState.svelte'
let {
state: accessState,
onChange,
disabled = false
}: {
state: FolderAccessState
onChange: () => void
disabled?: boolean
} = $props()
let selectedNames = $derived(accessState.selectedScopes.map((scope) => scope.name).join(', '))
let selectionSummary = $derived(
accessState.selectedScopes.length === accessState.scopes.length
? 'All folders, personal and workspace-wide context'
: accessState.selectedScopes.length === 0
? 'No folders selected'
: accessState.selectedScopes
.slice(0, 2)
.map((scope) => scope.name)
.join(', ') +
(accessState.selectedScopes.length > 2
? ` +${accessState.selectedScopes.length - 2}`
: '')
)
</script>
<div class="flex min-w-0 shrink-0 flex-col gap-2">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-xs text-primary">Working folders</p>
<p class="mt-1 truncate text-xs text-secondary" title={selectedNames || selectionSummary}
>{selectionSummary}</p
>
</div>
<Button variant="subtle" unifiedSize="sm" {disabled} onclick={onChange}>Change</Button>
</div>
</div>
@@ -1,116 +0,0 @@
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,326 @@
export type AccessEntryKind =
| 'instruction'
| 'skill'
| 'mcp'
| 'script'
| 'flow'
| 'app'
| 'resource'
| 'variable'
export type AccessEntry = {
id: string
kind: AccessEntryKind
name: string
path: string
description: string
}
export type AccessScope = {
id: string
kind: 'personal' | 'workspace' | 'folder'
name: string
path?: string
description: string
entries: AccessEntry[]
}
export const ACCESS_ENTRY_KIND_ORDER: AccessEntryKind[] = [
'mcp',
'instruction',
'skill',
'script',
'flow',
'app',
'resource',
'variable'
]
export const ACCESS_ENTRY_LABELS: Record<AccessEntryKind, string> = {
instruction: 'Instruction',
skill: 'Skill',
mcp: 'MCP server',
script: 'Script',
flow: 'Flow',
app: 'App',
resource: 'Resource',
variable: 'Variable'
}
const folderSpecs = [
{
name: 'Finance',
slug: 'finance',
subject: 'financial records',
scriptCount: 8,
flowCount: 5,
appCount: 2
},
{
name: 'Customer support',
slug: 'customer_support',
subject: 'support tickets',
scriptCount: 7,
flowCount: 6,
appCount: 2
},
{
name: 'Data platform',
slug: 'data_platform',
subject: 'datasets',
scriptCount: 9,
flowCount: 5,
appCount: 2
},
{
name: 'Engineering',
slug: 'engineering',
subject: 'releases',
scriptCount: 8,
flowCount: 5,
appCount: 2
},
{
name: 'Growth',
slug: 'growth',
subject: 'campaigns',
scriptCount: 6,
flowCount: 4,
appCount: 2
},
{
name: 'Operations',
slug: 'operations',
subject: 'orders',
scriptCount: 9,
flowCount: 6,
appCount: 3
},
{
name: 'People',
slug: 'people',
subject: 'employee onboarding',
scriptCount: 5,
flowCount: 4,
appCount: 1
}
] as const
function entry(
folder: string,
kind: AccessEntryKind,
name: string,
path: string,
description: string
): AccessEntry {
return { id: `${folder}:${kind}:${path}`, kind, name, path, description }
}
function folderScope(spec: (typeof folderSpecs)[number], index: number): AccessScope {
const { name, slug, subject, scriptCount, flowCount, appCount } = spec
const entries: AccessEntry[] = [
entry(
slug,
'instruction',
`${name} working agreement`,
`f/${slug}/instructions`,
`How the agent should handle ${subject} work.`
),
entry(
slug,
'skill',
`${name} specialist`,
`f/${slug}/skills/specialist`,
`Domain guidance and repeatable procedures for ${subject}.`
)
]
const scriptNames = [
`Import ${subject}`,
`Normalize ${subject}`,
`Sync ${subject} to warehouse`,
`Validate ${subject}`,
`Export ${subject} report`,
`Archive stale ${subject}`,
`Backfill ${subject}`,
`Notify ${subject} owners`,
`Reconcile ${subject}`
]
const flowNames = [
`${name} intake`,
`${name} approval`,
`${name} daily sync`,
`${name} escalation`,
`${name} weekly review`,
`${name} lifecycle`
]
const appNames = [`${name} dashboard`, `${name} operations console`, `${name} admin`]
entries.push(
...scriptNames
.slice(0, scriptCount)
.map((label, itemIndex) =>
entry(
slug,
'script',
label,
`f/${slug}/scripts/${itemIndex + 1}`,
`Processes ${subject} for the ${name.toLowerCase()} team.`
)
),
...flowNames
.slice(0, flowCount)
.map((label, itemIndex) =>
entry(
slug,
'flow',
label,
`f/${slug}/flows/${itemIndex + 1}`,
`Coordinates ${subject} across the team.`
)
),
...appNames
.slice(0, appCount)
.map((label, itemIndex) =>
entry(
slug,
'app',
label,
`f/${slug}/apps/${itemIndex + 1}`,
`Operational view for ${subject}.`
)
)
)
if (index % 3 !== 1) {
entries.push(
entry(
slug,
'resource',
`${name} database`,
`$res:f/${slug}/database`,
`Connection configuration used by this folder.`
)
)
}
if (index % 3 === 0) {
entries.push(
entry(
slug,
'mcp',
`${name} gateway`,
`$res:f/${slug}/mcp_gateway`,
`External tools scoped to ${subject}.`
)
)
}
if (index % 4 !== 2) {
entries.push(
entry(
slug,
'variable',
`${slug.toUpperCase()}_REGION`,
`$var:f/${slug}/region`,
`Default region for ${subject} operations.`
)
)
}
return {
id: `folder:${slug}`,
kind: 'folder',
name,
path: `f/${slug}`,
description: `Everything the agent can use for ${subject}.`,
entries: entries.sort((a, b) => a.name.localeCompare(b.name))
}
}
const virtualScopes: AccessScope[] = [
{
id: 'personal',
kind: 'personal',
name: 'Personal',
description: 'Preferences and guidance that apply only to you.',
entries: [
entry(
'personal',
'instruction',
'My working preferences',
'personal/instructions',
'Favor concise explanations and show proposed changes before applying them.'
),
entry(
'personal',
'skill',
'My review checklist',
'personal/skills/review_checklist',
'A personal checklist for reviewing generated work.'
)
]
},
{
id: 'workspace',
kind: 'workspace',
name: 'Workspace-wide',
description: 'Shared context available across every workspace folder.',
entries: [
entry(
'workspace',
'instruction',
'Workspace conventions',
'workspace/instructions',
'Shared naming, testing, and deployment conventions.'
),
entry(
'workspace',
'skill',
'Windmill conventions',
'workspace/skills/windmill',
'Standard patterns for scripts, flows, apps, and resources.'
),
entry(
'workspace',
'mcp',
'Documentation',
'$res:u/admin/documentation_mcp',
'Searches internal product and engineering documentation.'
),
entry(
'workspace',
'resource',
'Shared object storage',
'$res:u/admin/object_storage',
'Workspace-wide object storage configuration.'
)
].sort((a, b) => a.name.localeCompare(b.name))
}
]
export const ACCESS_SCOPES: AccessScope[] = [...virtualScopes, ...folderSpecs.map(folderScope)]
export const WORKSPACE_ITEM_KINDS = new Set<AccessEntryKind>([
'script',
'flow',
'app',
'resource',
'variable'
])
export function scopeRecap(scope: AccessScope): string {
const counts = ACCESS_ENTRY_KIND_ORDER.map((kind) => ({
kind,
count: scope.entries.filter((entry) => entry.kind === kind).length
})).filter(({ count }) => count > 0)
const itemCounts = counts.filter(({ kind }) => WORKSPACE_ITEM_KINDS.has(kind))
const collapseItems = itemCounts.length > 2
const parts = counts
.filter(({ kind }) => !collapseItems || !WORKSPACE_ITEM_KINDS.has(kind))
.map(({ kind, count }) => {
const label = kind === 'mcp' ? 'MCP' : ACCESS_ENTRY_LABELS[kind].toLowerCase()
return `${count} ${label}${count === 1 ? '' : 's'}`
})
if (collapseItems) {
parts.push(`${itemCounts.reduce((total, { count }) => total + count, 0)} items`)
}
return parts.join(' · ')
}
@@ -0,0 +1,42 @@
import { ACCESS_SCOPES, type AccessScope } from './folderAccess'
import { isEditableFolderSelected, setEditableFolderSelected } from './selectedEditableFolders'
export class FolderAccessState {
private selectionVersion = $state(0)
expanded = $state<Record<string, boolean>>({})
constructor(private readonly workspace: () => string) {}
get scopes(): AccessScope[] {
return ACCESS_SCOPES
}
isSelected(id: string): boolean {
this.selectionVersion
return isEditableFolderSelected(this.workspace(), id)
}
get selectedScopes(): AccessScope[] {
return this.scopes.filter((scope) => this.isSelected(scope.id))
}
get selectedEntries() {
return this.selectedScopes.flatMap((scope) => scope.entries)
}
setSelected(id: string, value: boolean) {
if (setEditableFolderSelected(this.workspace(), id, value)) this.selectionVersion++
}
setAll(value: boolean) {
let changed = false
for (const scope of this.scopes) {
changed = setEditableFolderSelected(this.workspace(), scope.id, value) || changed
}
if (changed) this.selectionVersion++
}
toggleExpanded(id: string) {
this.expanded[id] = !this.expanded[id]
}
}
@@ -1,10 +0,0 @@
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,8 @@
import { createPathsPreference } from '../enabledPathsPreference'
// No agent tool or prompt builder reads this preference: it only controls the
// context-summary presentation and must not be interpreted as an access boundary.
const preference = createPathsPreference('wm_ai_editable_folders', true)
export const isEditableFolderSelected = preference.isEnabled
export const setEditableFolderSelected = preference.setEnabled
@@ -1,77 +0,0 @@
<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}
@@ -1,187 +0,0 @@
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 }
]