Files
windmill/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte
T
AlexRV12andClaude Opus 5 6cd70a8e08 feat: filter ai session tools to the user's workspace capabilities (#10719)
* feat: filter ai session tools to the user's workspace capabilities

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: correct and tighten comments on the session capability filter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate session deploy tools on DisableDirectDeployment and the pipeline prompt

* fix: gate create_folder on the deploy capability

* refactor: assemble session prompt and tools through one seam

* docs: state the capability filter as best-effort, not a guarantee

* refactor: take the whole deploy gate from the shared preflight

`checkDeployPermission` now evaluates `DisableDirectDeployment` and folds superadmin
into the admin bypass itself, so the resolver's local composition of those two terms
is redundant. Delegate outright and drop the protection-rule fetch it needed, along
with the two tests that restated rule semantics the preflight's own suite now pins.

The preflight's per-kind narrowing stays unused: the filter runs on tool names, before
the model has named a kind, so a direct-deployment lock withholds the deploy tools for
schedules and triggers too.

* fix: address review findings on the session capability filter

Six findings from the Claude and Codex review rounds.

- `discard_local_draft` is ungated. The backend exempts discarding your OWN draft
  from `require_can_write_path` precisely so drafts stay cleanable after a role
  change; gating it stranded that cleanup.
- `deploy` splits into `deploy` and `deploy_gated_kinds`, mirroring
  `deployPermissionForKind`. A direct-deployment lock stops only the kinds that
  reach `check_deploy_rules`, so schedules and triggers stay deployable and the
  two kind-taking deploy tools survive the lock; `create_folder` does not, folder
  being a gated kind. The prompt now names the lock and what it leaves deployable,
  instead of implying nothing can be deployed.
- `COVERED_ENDPOINTS` keyed `createApp` / `updateApp`, which the MCP catalog does
  not expose; the app-authoring endpoints it does expose, `createAppRawSource` and
  `updateAppRawSource`, were uncovered and reachable through `call_api_endpoint`.
- The YOLO tooltip listed tools a restricted session never ships. Both it and the
  token estimate now read one `shippedTools`, and `sessionAccess` is reactive so
  the UI follows the resolution.

* fix: restore the covered API-catalog names for the raw-app endpoints

`COVERED_ENDPOINTS` is matched against `EndpointTool.name`, which openapi.yaml
overrides with `x-mcp-tool-name` for these two operations: `createAppRawSource`
and `updateAppRawSource` are served as `createApp` and `updateApp`
(`mcp/auto_generated_endpoints.rs`). Keying them by operationId left both raw-app
POST endpoints discoverable and callable through the API catalog tools. Restore
the exposed names and record why they differ from the operationIds.

* docs: state each capability invariant once, and document the draft discard

The asymmetric admin/operator precedence was restated three times in
sessionAccess.ts and again in its test, the fail-open rationale twice, and the
deploy split across four sites. Each now lives at the one place someone would
break it, within the four-line budget, with the other sites pointing at it.

Ungating discard_local_draft left it undocumented for the read-only profile,
which is the profile the backend exemption exists for: the only bullet naming it
sits under the draft-writing gate, beneath an opener saying no change is
possible. Add the one line that profile needs.

* docs: record why the session tool filter runs unconditionally

The filter would strip everything from a non-GLOBAL toolset, whose names carry no
policy entries. That cannot happen — `changeMode` refuses to move a session chat
out of GLOBAL, and `sessionAccess` is only ever set for session chats — but the
dependency was not visible at the filter itself.

* fix: match the server's deploy gate exactly, never exceed it

The filter must be as strict as the server and no stricter. Schedules and triggers
reach no deploy rule — `check_deploy_rules` runs only from the gated kinds' handlers
— so no workspace refuses `deploy_workspace_item` or `delete_workspace_item`
outright, whatever refusal `checkDeployPermission` reports. Gating them on a deploy
capability withheld operations the server performs.

Neither tool now requires a capability. Deploying still needs a draft to deploy, so
it keeps the authoring relevance; deleting a deployed item does not, so it is
ungated. `deploy` returns to one capability, covering the kinds the rules gate, and
`create_folder` — whose kind is one of them — is the only tool that names it. The
session-state note now states which kinds a refusing workspace still accepts.

* feat: gate the new app-runnable preview tool on run_preview

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: fail open when whoami resolves without a role

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: count plan-mode tools in the shipped toolset

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* docs: drop the dead capability assertion and the repeated deploy rationale

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: drop dead code and a duplicated invariant from the session filter

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: reduce SessionAccess to the capability set it is read for

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: gate session tools on permission alone, never on relevance

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* test: pin the filter to the outbound request and widen the description sweep

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: collapse SessionAccess to a capability set and merge adjacent gates

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: gate get_db_schema on run_preview, it runs a query script

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: reuse the cached workspace role and derive the exhaustiveness list from assembly

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* revert: keep tool names in descriptions that ship with them

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: let an admin who is also an operator deploy, as the server does

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: derive the deploy capability from the protection rules alone

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: stop the datatable instructions naming a tool a session may not have

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: keep the prompt and tool results honest for a profile that cannot draft

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: move tool policies onto the tools and gate kinds per handler

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* chore: tighten stale comments and name deploy in the operator prompt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: keep the assembled tool list raw so narrowing can clone its defs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: point an operator at a workspace admin for code the role refuses

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* fix: resolve session permissions when the assistant settings modal opens

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L99mAR4LitqTcYY1Kn1ATH

* refactor: return per-chat tool schemas instead of writing them to shared tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: forward this through the eval tool wrapper so tools see their sent def

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test: pin identity and contents in the session filter and schema tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: note why the overhead estimate skips per-chat tool schemas

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:59:54 +02:00

242 lines
10 KiB
Svelte

<!--
@component
Everything the chat carries into a turn beyond the conversation itself: its tools, the
skills and MCP servers turned on for it, and the custom instructions in force. Each of
those outlives the moment it was set — a skill turned on last week and an instruction
written once both go on steering every answer — so this is the one place they are all
accounted for and changed.
The trigger sits in the composer next to the model pill; `open(section)` is for the
callers that already know which section they mean, such as the "+" menu's Manage entries.
-->
<script lang="ts" module>
export type AssistantSettingsSection = 'tools' | 'skills' | 'instructions' | 'mcp' | 'files'
</script>
<script lang="ts">
import { BookOpen, Boxes, Paperclip, Plug, ScrollText, SlidersHorizontal } from 'lucide-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'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { getAiChatManager } from './aiChatManagerContext'
import { summarizeTools } from './agentContext'
import AssistantToolsSection from './AssistantToolsSection.svelte'
import AssistantSkillsSection from './AssistantSkillsSection.svelte'
import AssistantInstructionsSection from './AssistantInstructionsSection.svelte'
import AssistantMcpSection from './AssistantMcpSection.svelte'
import AssistantFilesSection from './AssistantFilesSection.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
const aiChatManager = getAiChatManager()
let isOpen = $state(false)
let section = $state<AssistantSettingsSection>('tools')
let skillCount = $state(0)
let mcpCount = $state(0)
let fileCount = $state(0)
// A section is mid-something the modal must not close under: a confirmation or a
// popover portaled to the body (where a click inside reads as a click outside this
// modal), or an editor holding text nobody has saved yet. While one is up, Escape
// and an outside click belong to the section, which answers them itself.
let toolsBusy = $state(false)
let skillsBusy = $state(false)
let instructionsBusy = $state(false)
let mcpBusy = $state(false)
let filesBusy = $state(false)
let blocksClose = $derived(toolsBusy || skillsBusy || instructionsBusy || mcpBusy || filesBusy)
// Which section is holding the modal open. The section on screen wins whenever it is
// itself blocking: it is the one that will answer the key, and preferring any other
// would take Escape away from the surface the user is looking at. Otherwise it is
// whichever section blocks from behind — in practice Instructions holding unsaved
// text, since the page-based sections park themselves when navigated away from.
let busyBySection = $derived<Record<AssistantSettingsSection, boolean>>({
tools: toolsBusy,
skills: skillsBusy,
instructions: instructionsBusy,
mcp: mcpBusy,
files: filesBusy
})
let blockingSection = $derived<AssistantSettingsSection | undefined>(
busyBySection[section]
? section
: (Object.keys(busyBySection) as AssistantSettingsSection[]).find((k) => busyBySection[k])
)
/** Escape with something blocking behind another section would look like a dead key:
* `Modal2` ignores it, and the section that answers it is not on screen. Show that
* section instead, so the reason the modal will not close is in front of the user.
*
* `stopImmediatePropagation` because this listener is the parent's and runs before
* the sections': showing a section makes it `active` within this same dispatch, and
* it would then answer the key it was revealed by — reverting the very draft the
* user was brought here to see. */
function onKeydown(event: KeyboardEvent) {
if (!isOpen || event.key !== 'Escape') return
const blocker = blockingSection
if (!blocker || blocker === section) return
event.preventDefault()
event.stopImmediatePropagation()
select(blocker)
}
// A session chat operates on its own (possibly forked) workspace without switching
// `workspaceStore`, and that is the workspace every list here is read under.
//
// `operatingWorkspace` is a plain getter over untracked state, so the store is read
// unconditionally rather than behind `??`: short-circuiting it would leave this
// derived with no dependency at all, frozen on the workspace it first saw.
let ws = $derived.by(() => {
const active = $operatingWorkspace
return aiChatManager.operatingWorkspace ?? active ?? ''
})
// The session's capability surface, not this turn's: the heading answers what the
// assistant can call here, so it must not shift when the autonomy picker does.
let tools = $derived(summarizeTools(aiChatManager.availableTools))
// `SidebarNavigation`'s item shape, which is what the instance and workspace settings
// navs are built from too.
let sections = $derived([
{ id: 'tools', label: 'Tools', icon: Boxes, count: tools.length },
{ id: 'skills', label: 'Skills', icon: BookOpen, count: skillCount },
{ id: 'instructions', label: 'Instructions', icon: ScrollText },
{ id: 'mcp', label: 'MCP connections', icon: Plug, count: mcpCount },
// The count is what is readable rather than what is attached: a locked folder under
// a heading about what the assistant can use would say the opposite of the truth.
{ id: 'files', label: 'Files & folders', icon: Paperclip, count: fileCount }
])
/** Opens on `target`, or back on whichever section was last read. */
export function open(target: AssistantSettingsSection = section) {
section = target
isOpen = true
refresh()
record('open')
record(target)
}
// `context_panel` is the kind registered in `feature_usage_ee.rs`; an unregistered
// pair is dropped silently, so the name is fixed there rather than here.
//
// The key vocabulary is the closed set in this signature and nothing else — a
// skill path or server path here would be workspace-authored text.
function record(key: 'open' | AssistantSettingsSection) {
logFeatureUsage('ai_session', 'context_panel', { key, workspace: ws })
}
function select(target: AssistantSettingsSection) {
if (target === section) return
section = target
record(target)
}
function refresh() {
// `globalSkills` and `mcpServers` are per-manager snapshots, and the enabled
// sets they derive from are shared: toggling a skill in one session leaves
// every other warm session's copy behind until its next send refreshes it.
// Opening this modal is the one moment the tool list has to be true, so it
// refreshes the same way the send path does — the active chat only.
//
// Never while a turn is in flight, though. These refreshes carry generation
// counters, so starting one invalidates the send's own: its `Promise.all`
// would return without applying results, and this one would then rewrite the
// tools and system prompt underneath a running turn. Mid-turn it shows the
// values the turn was actually given, which is the honest answer anyway.
if (!aiChatManager.loading && !aiChatManager.sendInFlight) {
void aiChatManager.refreshGlobalSkills()
void aiChatManager.refreshMcpServers()
void aiChatManager.refreshSessionAccess()
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<Tooltip small placement="top">
<Button
unifiedSize="2xs"
variant="subtle"
iconOnly
startIcon={{ icon: SlidersHorizontal }}
aria-label="Assistant settings"
onClick={() => open()}
/>
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">Assistant settings</p>
<p class="mt-1">The tools, skills, instructions and MCP servers this chat can use.</p>
</div>
{/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}
<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)}
/>
</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
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
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
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>
</div>
</div>
</Modal2>