mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: organize AI session context by folder
This commit is contained in:
@@ -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 }
|
||||
]
|
||||
Reference in New Issue
Block a user