mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-28 00:02:42 +00:00
* 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>
242 lines
10 KiB
Svelte
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>
|