mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
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:
co-authored by
Claude Opus 5
parent
6e1ef93f32
commit
164c701d90
@@ -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 }
|
||||
]
|
||||
Reference in New Issue
Block a user