Merge remote-tracking branch 'origin/main' into license-app-only-implicit-promotion

This commit is contained in:
Ruben Fiszel
2026-09-04 15:15:21 +02:00
35 changed files with 3164 additions and 938 deletions
+1 -1
View File
@@ -1 +1 @@
cea88af9477b4e33832fbad5c5aefbc62762067f 00bf1dde9c396382a2db30ff7a5f1703c2611304
+41
View File
@@ -29,6 +29,10 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token", "token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/spreadsheets"], "scopes": ["https://www.googleapis.com/auth/spreadsheets"],
"scope_options": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/spreadsheets.readonly"
],
"extra_params": { "extra_params": {
"access_type": "offline", "access_type": "offline",
"prompt": "consent" "prompt": "consent"
@@ -38,6 +42,11 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token", "token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/drive"], "scopes": ["https://www.googleapis.com/auth/drive"],
"scope_options": [
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/drive"
],
"extra_params": { "extra_params": {
"access_type": "offline", "access_type": "offline",
"prompt": "consent" "prompt": "consent"
@@ -47,6 +56,13 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token", "token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/gmail.send"], "scopes": ["https://www.googleapis.com/auth/gmail.send"],
"scope_options": [
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.compose",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.labels"
],
"extra_params": { "extra_params": {
"access_type": "offline", "access_type": "offline",
"prompt": "consent" "prompt": "consent"
@@ -56,6 +72,12 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token", "token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/calendar.events"], "scopes": ["https://www.googleapis.com/auth/calendar.events"],
"scope_options": [
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.events.readonly",
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar"
],
"extra_params": { "extra_params": {
"access_type": "offline", "access_type": "offline",
"prompt": "consent" "prompt": "consent"
@@ -65,6 +87,12 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token", "token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/forms"], "scopes": ["https://www.googleapis.com/auth/forms"],
"scope_options": [
"https://www.googleapis.com/auth/forms",
"https://www.googleapis.com/auth/forms.body",
"https://www.googleapis.com/auth/forms.body.readonly",
"https://www.googleapis.com/auth/forms.responses.readonly"
],
"extra_params": { "extra_params": {
"access_type": "offline", "access_type": "offline",
"prompt": "consent" "prompt": "consent"
@@ -74,6 +102,10 @@
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth", "auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token", "token_url": "https://oauth2.googleapis.com/token",
"scopes": ["https://www.googleapis.com/auth/cloud-platform"], "scopes": ["https://www.googleapis.com/auth/cloud-platform"],
"scope_options": [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/cloud-platform.read-only"
],
"extra_params": { "extra_params": {
"access_type": "offline", "access_type": "offline",
"prompt": "consent" "prompt": "consent"
@@ -88,6 +120,15 @@
"https://www.googleapis.com/auth/admin.directory.user.security", "https://www.googleapis.com/auth/admin.directory.user.security",
"https://www.googleapis.com/auth/admin.directory.orgunit" "https://www.googleapis.com/auth/admin.directory.orgunit"
], ],
"scope_options": [
"https://www.googleapis.com/auth/admin.directory.user",
"https://www.googleapis.com/auth/admin.directory.user.readonly",
"https://www.googleapis.com/auth/admin.directory.group",
"https://www.googleapis.com/auth/admin.directory.group.readonly",
"https://www.googleapis.com/auth/admin.directory.orgunit",
"https://www.googleapis.com/auth/admin.directory.orgunit.readonly",
"https://www.googleapis.com/auth/admin.directory.user.security"
],
"extra_params": { "extra_params": {
"access_type": "offline", "access_type": "offline",
"prompt": "consent" "prompt": "consent"
+2
View File
@@ -88,6 +88,8 @@ pub struct OAuthConfig {
#[serde(default = "empty_string")] #[serde(default = "empty_string")]
pub token_url: String, pub token_url: String,
pub userinfo_url: Option<String>, pub userinfo_url: Option<String>,
/// The registry JSON may also carry `scope_options`, a frontend-only pick
/// list for the connect dialog; it is deliberately not modelled here.
pub scopes: Option<Vec<String>>, pub scopes: Option<Vec<String>>,
/// Default scopes for the client-credentials (2-legged) flow. These differ /// Default scopes for the client-credentials (2-legged) flow. These differ
/// from the authorization-code `scopes` for most providers (member/consent /// from the authorization-code `scopes` for most providers (member/consent
@@ -21,9 +21,10 @@
} from '$lib/gen' } from '$lib/gen'
import { emptyString, truncateRev, urlize } from '$lib/utils' import { emptyString, truncateRev, urlize } from '$lib/utils'
import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry' import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry'
import { createEventDispatcher, onDestroy, tick, untrack } from 'svelte' import { createEventDispatcher, onDestroy, tick } from 'svelte'
import Path from './Path.svelte' import Path from './Path.svelte'
import { Button, RadioCard, Skeleton } from './common' import { ListRow, RadioCard, Skeleton } from './common'
import { useListHighlight } from './common/listRow/listHighlight.svelte'
import ApiConnectForm from './ApiConnectForm.svelte' import ApiConnectForm from './ApiConnectForm.svelte'
import SearchItems from './SearchItems.svelte' import SearchItems from './SearchItems.svelte'
import WhitelistIp from './WhitelistIp.svelte' import WhitelistIp from './WhitelistIp.svelte'
@@ -42,7 +43,6 @@
import SyncResourceTypes from './SyncResourceTypes.svelte' import SyncResourceTypes from './SyncResourceTypes.svelte'
import Label from './Label.svelte' import Label from './Label.svelte'
import ResourcePathHint from './ResourcePathHint.svelte' import ResourcePathHint from './ResourcePathHint.svelte'
import { twMerge } from 'tailwind-merge'
interface Props { interface Props {
step?: number step?: number
@@ -1027,15 +1027,8 @@
// Both lists start undefined and render skeletons; "nothing found" only means something // Both lists start undefined and render skeletons; "nothing found" only means something
// once they have landed. // once they have landed.
let listsLoaded = $derived(rankedConnectsManual !== undefined && rankedConnects !== undefined) let listsLoaded = $derived(rankedConnectsManual !== undefined && rankedConnects !== undefined)
let highlightedIndex = $state(-1)
const rowDomId = (index: number) => `resource-type-row-${index}` const rowDomId = (index: number) => `resource-type-row-${index}`
// Set at hover time rather than up front, so only the descriptions the row actually cut
// off carry a tooltip.
function titleIfTruncated(e: MouseEvent & { currentTarget: HTMLElement }) {
const el = e.currentTarget
el.title = el.scrollWidth > el.clientWidth ? (el.textContent?.trim() ?? '') : ''
}
const oauthRowOffset = $derived(customKeys.length) const oauthRowOffset = $derived(customKeys.length)
const otherRowOffset = $derived(customKeys.length + (rankedConnects?.length ?? 0)) const otherRowOffset = $derived(customKeys.length + (rankedConnects?.length ?? 0))
@@ -1054,53 +1047,23 @@
return best return best
} }
// Filtering reshuffles the rows under the highlight: point it at the best match so Enter const highlight = useListHighlight({
// takes the top hit, and drop it entirely once the filter is cleared. count: () => navItems.length,
$effect(() => { rowId: rowDomId,
navItems // Sections are rendered in a fixed order, so the best match is not necessarily the
filter // first row; Enter should still take the top hit.
untrack(() => (highlightedIndex = searching ? bestMatchIndex() : -1)) restingIndex: () => (searching ? bestMatchIndex() : -1),
onActivate: (index) => {
const item = navItems[index]
if (!item) return
item.oauth ? connectOauth(item.key) : selectFromOthers(item.key)
},
activateEnterFrom: [SEARCH_INPUT_ID]
}) })
// Scrolling rows under a resting pointer makes the browser fire `mouseenter` on each one,
// which would drag the highlight back under the cursor as the arrow keys move it. Only a
// real pointer move hands the highlight back to the mouse.
let pointerOwnsHighlight = $state(true)
function highlightHovered(index: number) {
if (pointerOwnsHighlight) highlightedIndex = index
}
function moveHighlight(delta: number) {
const count = navItems.length
if (count === 0) return
pointerOwnsHighlight = false
// Rows are tabbable buttons, so focus can sit on one. Enter then activates whatever is
// focused, which has to stay the highlighted row.
const rowWasFocused = document.activeElement?.id?.startsWith('resource-type-row-') ?? false
highlightedIndex =
highlightedIndex < 0
? delta > 0
? 0
: count - 1
: (highlightedIndex + delta + count) % count
const row = document.getElementById(rowDomId(highlightedIndex))
row?.scrollIntoView({ block: 'nearest' })
if (rowWasFocused) row?.focus()
}
function onListKeydown(e: KeyboardEvent) { function onListKeydown(e: KeyboardEvent) {
if (step !== 1) return if (step !== 1) return
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { highlight.onKeydown(e)
e.preventDefault()
moveHighlight(e.key === 'ArrowDown' ? 1 : -1)
} else if (e.key === 'Enter' && (e.target as HTMLElement)?.id === SEARCH_INPUT_ID) {
// A focused row activates itself on Enter; this covers Enter typed in the search field.
const item = navItems[highlightedIndex]
if (!item) return
e.preventDefault()
item.oauth ? connectOauth(item.key) : selectFromOthers(item.key)
}
} }
let editScopes = $state(false) let editScopes = $state(false)
@@ -1132,7 +1095,7 @@
<div <div
class="flex flex-col h-full min-h-0" class="flex flex-col h-full min-h-0"
onkeydown={onListKeydown} onkeydown={onListKeydown}
onpointermove={() => (pointerOwnsHighlight = true)} onpointermove={highlight.pointerMoved}
> >
<div class="shrink-0 pb-4"> <div class="shrink-0 pb-4">
<div class="relative w-full"> <div class="relative w-full">
@@ -1146,28 +1109,6 @@
</div> </div>
</div> </div>
{#snippet resourceRow(key: string)}
<div class="flex flex-row items-center gap-4 w-full min-w-0 text-left">
<div class="shrink-0">
<IconedResourceType name={key} silent width="20px" height="20px" />
</div>
<div class="flex flex-col gap-1 min-w-0">
<div class="flex flex-row items-baseline gap-2 min-w-0">
<span class="truncate leading-5">{resourceTypeDisplayName(key)}</span>
<span class="shrink-0 font-mono text-2xs font-normal text-hint">{key}</span>
</div>
{#if resourceTypeDescriptions[key]}
<span
class="truncate text-xs font-normal leading-4 text-secondary"
onmouseenter={titleIfTruncated}
>
{plainDescription(resourceTypeDescriptions[key])}
</span>
{/if}
</div>
</div>
{/snippet}
{#snippet sectionHeading(title: string, count: number)} {#snippet sectionHeading(title: string, count: number)}
<h2 class="mb-3 text-2xs font-normal uppercase text-secondary"> <h2 class="mb-3 text-2xs font-normal uppercase text-secondary">
{title}{#if searching}<span class="ml-2 text-hint">{count}</span>{/if} {title}{#if searching}<span class="ml-2 text-hint">{count}</span>{/if}
@@ -1175,26 +1116,29 @@
{/snippet} {/snippet}
{#snippet resourceButton(key: string, index: number, oauth: boolean)} {#snippet resourceButton(key: string, index: number, oauth: boolean)}
<Button {#snippet icon()}
<IconedResourceType name={key} silent width="20px" height="20px" />
{/snippet}
{#snippet title()}
<span class="truncate leading-5">{resourceTypeDisplayName(key)}</span>
<span class="shrink-0 font-mono text-2xs font-normal text-hint">{key}</span>
{/snippet}
{#snippet subtitle()}
{plainDescription(resourceTypeDescriptions[key])}
{/snippet}
<!-- `highlighted`: the pointer moves the same highlight the arrow keys move, so
the row's own hover is off — two lit rows at once would be ambiguous. -->
<ListRow
id={rowDomId(index)} id={rowDomId(index)}
aiId={`app-connect-inner-${oauth ? 'oauth-' : ''}${key}`} aiId={`app-connect-inner-${oauth ? 'oauth-' : ''}${key}`}
aiDescription={`Connect to ${key}${oauth ? ' with the instance OAuth client' : ''}`} aiDescription={`Connect to ${key}${oauth ? ' with the instance OAuth client' : ''}`}
unifiedSize="md" {icon}
variant="subtle" {title}
btnClasses={twMerge( subtitle={resourceTypeDescriptions[key] ? subtitle : undefined}
'justify-start px-3 h-auto py-3 scroll-my-2', highlighted={index === highlight.index}
// The pointer moves the same highlight the arrow keys move, so the variant's onMouseEnter={() => highlight.hovered(index)}
// own hover is off: two lit rows at once would be ambiguous. onClick={() => (oauth ? connectOauth(key) : selectFromOthers(key))}
'hover:bg-transparent', />
// `!` so the highlight also wins on the row the pointer is over, whose own
// hover was turned off just above.
index === highlightedIndex ? '!bg-surface-hover' : ''
)}
on:mouseenter={() => highlightHovered(index)}
on:click={() => (oauth ? connectOauth(key) : selectFromOthers(key))}
>
{@render resourceRow(key)}
</Button>
{/snippet} {/snippet}
<div class="flex-1 min-h-0 overflow-y-auto"> <div class="flex-1 min-h-0 overflow-y-auto">
@@ -1213,7 +1157,7 @@
{#if customKeys.length > 0} {#if customKeys.length > 0}
<section> <section>
{@render sectionHeading('Custom resource types', customKeys.length)} {@render sectionHeading('Custom resource types', customKeys.length)}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-0.5">
{#each customKeys as key, i} {#each customKeys as key, i}
{@render resourceButton(key, i, false)} {@render resourceButton(key, i, false)}
{/each} {/each}
@@ -1227,7 +1171,7 @@
'Instance-configured OAuth APIs', 'Instance-configured OAuth APIs',
rankedConnects?.length ?? 0 rankedConnects?.length ?? 0
)} )}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-0.5">
{#if rankedConnects} {#if rankedConnects}
{#each rankedConnects as { key }, i} {#each rankedConnects as { key }, i}
{@render resourceButton(key, oauthRowOffset + i, true)} {@render resourceButton(key, oauthRowOffset + i, true)}
@@ -1259,7 +1203,7 @@
</div> </div>
{/if} {/if}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-0.5">
{#if rankedConnectsManual} {#if rankedConnectsManual}
{#each otherKeys as key, i} {#each otherKeys as key, i}
{@render resourceButton(key, otherRowOffset + i, false)} {@render resourceButton(key, otherRowOffset + i, false)}
@@ -1532,7 +1476,7 @@
> >
{#if editScopes} {#if editScopes}
<OauthScopes bind:scopes /> <OauthScopes bind:scopes options={registryEntry()?.scope_options} />
{:else} {:else}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#each scopes as scope} {#each scopes as scope}
+87 -24
View File
@@ -1,51 +1,114 @@
<script lang="ts"> <script lang="ts">
import { Button } from './common' import { Button } from './common'
import TextInput from './text_input/TextInput.svelte'
import Checkbox from './common/checkbox/Checkbox.svelte'
import { Minus, Plus } from 'lucide-svelte' import { Minus, Plus } from 'lucide-svelte'
interface Props { interface Props {
scopes?: string[] scopes?: string[]
/** Scopes the provider is known to accept, offered as checkboxes. Anything
* not in this list stays editable as free text below them. */
options?: string[]
} }
let { scopes = $bindable() }: Props = $props() let { scopes = $bindable(), options = [] }: Props = $props()
// Ticked options and free-text rows are kept apart from `scopes` (the only
// value the parent binds) so a row can pass through an option's exact value
// while typing (`…/calendar` on the way to `…/calendar.acls`) without ticking
// or unticking anything. `lastWritten` tells a parent-side reset apart from
// the echo of our own write.
let ticked: string[] = $state([])
let custom: string[] = $state([])
let lastWritten: string | undefined = undefined
$effect.pre(() => { $effect.pre(() => {
if (!scopes) { if (!scopes) {
scopes = [] scopes = []
} }
const json = JSON.stringify([scopes, options])
if (json != lastWritten) {
lastWritten = json
ticked = scopes.filter((v) => options.includes(v))
custom = scopes.filter((v) => !options.includes(v))
}
}) })
function write(nextTicked: string[], rows: string[]) {
ticked = nextTicked
custom = rows
scopes = [...nextTicked.filter((o) => !rows.includes(o)), ...rows]
lastWritten = JSON.stringify([scopes, options])
}
// Ticking an option absorbs a free-text row holding the same value. The
// target state comes from `ticked`, not the DOM: `Checkbox` re-asserts its
// `checked` prop on every click, so the input already reads the old value
// again by the time `change` fires.
function toggle(option: string, on: boolean) {
const rest = ticked.filter((o) => o != option)
write(on ? [...rest, option] : rest, on ? custom.filter((r) => r != option) : custom)
}
function setRow(i: number, value: string) {
const rows = [...custom]
rows[i] = value
write(ticked, rows)
}
</script> </script>
{#if scopes && Array.isArray(scopes)} {#if options.length > 0}
{#each scopes as v, i} <div class="flex flex-col gap-1 mb-2">
<div class="flex flex-row max-w-md mb-2"> {#each options as option (option)}
<input type="text" bind:value={scopes[i]} /> <label class="flex items-center gap-2 text-xs">
<Button <Checkbox
variant="default" checked={ticked.includes(option)}
size="xs" onChange={() => toggle(option, !ticked.includes(option))}
btnClasses="mx-6" />
on:click={() => { <span class="font-mono break-all">{option}</span>
scopes = scopes?.filter((el) => el != v) </label>
}} {/each}
startIcon={{ icon: Minus }} </div>
iconOnly <span class="text-xs text-secondary">Custom scopes</span>
/>
</div>
{/each}
{/if} {/if}
{#each custom as v, i (i)}
<div class="flex flex-row max-w-md mb-2">
<TextInput
value={v}
size="sm"
inputProps={{ oninput: (e) => setRow(i, e.currentTarget.value) }}
/>
<Button
variant="default"
unifiedSize="sm"
btnClasses="mx-6"
onclick={() => {
write(
ticked,
custom.filter((_, j) => j != i)
)
}}
startIcon={{ icon: Minus }}
iconOnly
/>
</div>
{/each}
<div class="flex items-center mt-1"> <div class="flex items-center mt-1">
<Button <Button
variant="default" variant="default"
hover="yo" unifiedSize="sm"
size="xs"
startIcon={{ icon: Plus }} startIcon={{ icon: Plus }}
on:click={() => { onclick={() => {
scopes = (scopes ?? []).concat('') write(ticked, [...custom, ''])
}} }}
> >
Add item Add item
</Button> </Button>
<span class="ml-2 text-xs text-primary font-normal"> {#if custom.length > 0}
({(scopes ?? []).length} item{(scopes ?? []).length > 1 ? 's' : ''}) <span class="ml-2 text-xs text-primary font-normal">
</span> ({custom.length} item{custom.length > 1 ? 's' : ''})
</span>
{/if}
</div> </div>
@@ -320,7 +320,9 @@
current.path = npath current.path = npath
} }
export async function save(): Promise<void> { /** Whether the write landed. It toasts its own failure, so most callers ignore this;
* one that follows the save with bookkeeping of its own has to know not to. */
export async function save(): Promise<boolean> {
const dirty = dirtyWorkspaces const dirty = dirtyWorkspaces
try { try {
for (const ws of dirty) { for (const ws of dirty) {
@@ -368,8 +370,10 @@
dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource` dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource`
) )
dispatch('refresh', current?.path ?? path) dispatch('refresh', current?.path ?? path)
return true
} catch (err) { } catch (err) {
sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true)
return false
} }
} }
</script> </script>
+1 -1
View File
@@ -102,7 +102,7 @@
transition:slide={animate || collapsable ? { duration: 200 } : { duration: 0 }} transition:slide={animate || collapsable ? { duration: 200 } : { duration: 0 }}
> >
{#if description} {#if description}
<div class="text-xs text-primary mt-1 mb-2">{@html description}</div> <div class="text-xs text-secondary mt-1 mb-2">{@html description}</div>
{/if} {/if}
<div class={twMerge('flex flex-col gap-6 grow min-h-0', headless ? '' : 'mt-4')}> <div class={twMerge('flex flex-col gap-6 grow min-h-0', headless ? '' : 'mt-4')}>
<div class={twMerge('grow min-h-0', clazz)}> <div class={twMerge('grow min-h-0', clazz)}>
@@ -63,6 +63,10 @@
/** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. */ /** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. */
const CHANGE_TIMEOUT = 200 const CHANGE_TIMEOUT = 200
/** Gap between the line numbers and the first character. Zero puts them flush,
* so a two-digit line reads as one token with the code. */
const LINE_DECORATIONS_WIDTH = 6
let changeTimeoutId: number | undefined = undefined let changeTimeoutId: number | undefined = undefined
// Monaco fires onDidChangeModelContent synchronously from within `setValue`, so without // Monaco fires onDidChangeModelContent synchronously from within `setValue`, so without
// this an authoritative overwrite reads as a user edit on the `input` event. // this an authoritative overwrite reads as a user edit on the `input` event.
@@ -112,7 +116,8 @@
minHeight = 1000, minHeight = 1000,
renderLineHighlight = 'none', renderLineHighlight = 'none',
suggestion, suggestion,
leadingChangeSync = false leadingChangeSync = false,
lineNumbersMinChars = 3
}: { }: {
lang: string lang: string
code?: string code?: string
@@ -149,6 +154,9 @@
* `code`; leave it off where each extra sync costs work downstream (an app * `code`; leave it off where each extra sync costs work downstream (an app
* code input feeding an autoRefresh runnable re-runs a job per sync). */ * code input feeding an autoRefresh runnable re-runs a job per sync). */
leadingChangeSync?: boolean leadingChangeSync?: boolean
/** Width of the line-number gutter, in characters. Same name, and same
* default, as `Editor`, so the two render line numbers alike. */
lineNumbersMinChars?: number
} = $props() } = $props()
let yPadding = MONACO_Y_PADDING let yPadding = MONACO_Y_PADDING
@@ -312,10 +320,12 @@
if (model.getLanguageId() !== lang) { if (model.getLanguageId() !== lang) {
const currentCode = model.getValue() const currentCode = model.getValue()
const uri = `file:///${hash}.${langToExt(lang)}` const uri = `file:///${hash}.${langToExt(lang)}`
const oldModel = model // The old model goes first: `langToExt` maps anything it does not know to
const newModel = meditor.createModel(currentCode, lang, mUri.parse(uri)) // `unknown`, so the new uri is usually the one this model already holds,
editor?.setModel(newModel) // and creating over an occupied uri throws ("model already exists").
oldModel.dispose() editor?.setModel(null)
model.dispose()
editor?.setModel(meditor.createModel(currentCode, lang, mUri.parse(uri)))
} }
// Update editor options for suggestions, validation decorations, and line numbers // Update editor options for suggestions, validation decorations, and line numbers
@@ -334,8 +344,8 @@
snippetsPreventQuickSuggestions: disableSuggestions snippetsPreventQuickSuggestions: disableSuggestions
}, },
lineNumbers: hideLineNumbers ? 'off' : 'on', lineNumbers: hideLineNumbers ? 'off' : 'on',
lineDecorationsWidth: hideLineNumbers ? 0 : 6, lineDecorationsWidth: hideLineNumbers ? 0 : LINE_DECORATIONS_WIDTH,
lineNumbersMinChars: hideLineNumbers ? 0 : 2, lineNumbersMinChars: hideLineNumbers ? 0 : lineNumbersMinChars,
// Hide validation squiggles and decorations // Hide validation squiggles and decorations
renderValidationDecorations: disableLinting ? 'off' : 'on', renderValidationDecorations: disableLinting ? 'off' : 'on',
// Hide the validation margin indicators // Hide the validation margin indicators
@@ -397,8 +407,11 @@
...(yPadding !== undefined ? { padding: { bottom: yPadding, top: yPadding } } : {}), ...(yPadding !== undefined ? { padding: { bottom: yPadding, top: yPadding } } : {}),
readOnly, readOnly,
renderLineHighlight, renderLineHighlight,
lineDecorationsWidth: 0, // Same conditional as `updateModelAndOptions`: created correct rather than
lineNumbersMinChars: 2, // created wide and narrowed a tick later, which a caller hiding the gutter
// would see as a flash of indent.
lineDecorationsWidth: hideLineNumbers ? 0 : LINE_DECORATIONS_WIDTH,
lineNumbersMinChars: hideLineNumbers ? 0 : lineNumbersMinChars,
fontSize: fontSize, fontSize: fontSize,
quickSuggestions: disableSuggestions quickSuggestions: disableSuggestions
? { other: false, comments: false, strings: false } ? { other: false, comments: false, strings: false }
@@ -201,6 +201,13 @@
} }
} }
/** Open the file chooser without the dropzone being clicked, for a caller whose
* affordance is a button elsewhere. The component is still what reads and filters
* the files, so the two paths cannot drift. */
export function openPicker() {
input?.click()
}
export function clearFiles() { export function clearFiles() {
files = undefined files = undefined
dispatchChange() dispatchChange()
@@ -20,6 +20,7 @@ export { default as TabFade } from './tabs/TabFade.svelte'
export { default as Tabs } from './tabs/Tabs.svelte' export { default as Tabs } from './tabs/Tabs.svelte'
export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte' export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte'
export { default as FileInput } from './fileInput/FileInput.svelte' export { default as FileInput } from './fileInput/FileInput.svelte'
export { default as ListRow } from './listRow/ListRow.svelte'
export { default as RadioCard } from './radioCard/RadioCard.svelte' export { default as RadioCard } from './radioCard/RadioCard.svelte'
export { default as Section } from '../Section.svelte' export { default as Section } from '../Section.svelte'
export { default as Url } from './Url.svelte' export { default as Url } from './Url.svelte'
@@ -0,0 +1,142 @@
<!--
@component
One row of a selectable list: an optional leading icon, a label line, one line of
secondary text under it, and optionally controls at the end that act on their own.
Borderless — rows sit in a `flex flex-col gap-1` and light up on hover rather than
living in a bordered card, which reads as heavy once a list runs to dozens of rows.
-->
<script lang="ts">
import type { Snippet } from 'svelte'
import { twMerge } from 'tailwind-merge'
import Button from '$lib/components/common/button/Button.svelte'
let {
title,
subtitle,
icon,
trailing,
onClick,
onMouseEnter,
highlighted,
id,
aiId,
aiDescription,
class: clazz = ''
}: {
/** The label line. A snippet so a caller can highlight a search match, or set a
* second identifier beside the name. */
title: Snippet
/** One line under the title, truncated. */
subtitle?: Snippet
icon?: Snippet
/** Controls at the end of the row that act on their own — a switch, a menu. A row
* that has them is a div with the label as an inner button, since a button cannot
* nest inside a button and a click would otherwise fire both. */
trailing?: Snippet
onClick?: () => void
onMouseEnter?: () => void
/** Given, the caller owns which row is lit — it moves the highlight with the
* keyboard — and the row's own hover is off: two lit rows at once are ambiguous. */
highlighted?: boolean
id?: string
aiId?: string
aiDescription?: string
class?: string
} = $props()
let ownHover = $derived(highlighted === undefined)
// Set at hover time rather than up front, so only the rows that actually cut their
// text off carry a tooltip.
function titleIfTruncated(e: MouseEvent & { currentTarget: HTMLElement }) {
const el = e.currentTarget
el.title = el.scrollWidth > el.clientWidth ? (el.textContent?.trim() ?? '') : ''
}
</script>
{#snippet body()}
<div class="flex flex-row items-center gap-4 w-full min-w-0 text-left">
{#if icon}
<div class="shrink-0">{@render icon()}</div>
{/if}
<div class="flex flex-col gap-0.5 min-w-0 grow">
<div class="flex flex-row items-baseline gap-2 min-w-0">
{@render title()}
</div>
{#if subtitle}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="truncate text-xs font-normal leading-4 text-secondary"
onmouseenter={titleIfTruncated}
>
{@render subtitle()}
</span>
{/if}
</div>
</div>
{/snippet}
{#if trailing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
{id}
class={twMerge(
'flex items-center gap-4 rounded-md px-3 py-3 scroll-my-2 transition-colors text-xs font-medium',
// Only a row that opens something lights up: a hover on a row whose label does
// nothing reads as an affordance that isn't there.
onClick ? (ownHover ? 'hover:bg-surface-hover' : highlighted ? 'bg-surface-hover' : '') : '',
clazz
)}
onmouseenter={onMouseEnter}
>
{#if onClick}
<!-- Its own padding and hover are off: both belong to the row around it, which
is what lights up and what the trailing controls sit inside. -->
<Button
type="button"
{aiId}
{aiDescription}
variant="subtle"
unifiedSize="md"
wrapperClasses="grow shrink min-w-0"
btnClasses="w-full min-w-0 justify-start p-0 h-auto bg-transparent hover:bg-transparent"
{onClick}
>
{@render body()}
</Button>
{:else}
<!-- No `onClick`: a button here would be a tab stop that does nothing. -->
<div class="flex grow min-w-0 text-left">{@render body()}</div>
{/if}
{@render trailing()}
</div>
{:else if !onClick}
<!-- Nothing to click and nothing trailing: a row that only reports. A `Button` here
would be a tab stop that lights up on hover and does nothing. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div {id} class={twMerge('px-3 py-3 text-xs font-medium', clazz)} onmouseenter={onMouseEnter}>
{@render body()}
</div>
{:else}
<Button
type="button"
{id}
{aiId}
{aiDescription}
variant="subtle"
unifiedSize="md"
btnClasses={twMerge(
'justify-start px-3 h-auto py-3 scroll-my-2',
ownHover ? '' : 'hover:bg-transparent',
// `!` so the highlight also wins on the row the pointer is over, whose own
// hover was turned off just above.
highlighted ? '!bg-surface-hover' : '',
clazz
)}
on:mouseenter={() => onMouseEnter?.()}
{onClick}
>
{@render body()}
</Button>
{/if}
@@ -0,0 +1,84 @@
import { untrack } from 'svelte'
/**
* The highlighted row of a searchable list: the one the arrow keys move and Enter
* activates, rendered by passing `highlighted` to `ListRow`.
*
* Pairs with a search field above the list — the arrows and Enter are answered while
* focus stays in it, so a query and a choice are one uninterrupted sequence.
*/
export function useListHighlight(opts: {
/** How many rows the list holds right now. */
count: () => number
/** The DOM id of the row at this index — the same `id` given to its `ListRow`. */
rowId: (index: number) => string
/** Where the highlight belongs when the list changes underneath it: the top hit while
* a search is on, and typically -1 (nothing lit) when it is not. */
restingIndex: () => number
/** Open the row at this index. */
onActivate: (index: number) => void
/** Ids of the elements whose Enter also activates the highlighted row — the search
* field. A focused row activates itself, so it is not one of these. */
activateEnterFrom?: string[]
}) {
let index = $state(-1)
// Scrolling rows under a resting pointer makes the browser fire `mouseenter` on each
// one, which would drag the highlight back under the cursor as the arrow keys move it.
// Only a real pointer move hands the highlight back to the mouse.
let pointerOwns = $state(true)
// Filtering reshuffles the rows under the highlight, so it goes back where the caller
// says it belongs rather than staying on a position that now means another row.
$effect(() => {
opts.count()
const resting = opts.restingIndex()
untrack(() => (index = resting))
})
function move(delta: number) {
const count = opts.count()
if (count === 0) return
pointerOwns = false
// Rows are tabbable, so focus can sit on one. Enter then activates whatever is
// focused, which has to stay the highlighted row — so any row counts, not just
// the lit one. Tab from the search field lands on the first row while the
// highlight rests on the best match, and testing only the lit row would leave
// focus behind and activate the wrong one.
const focusedId = document.activeElement?.id
const rowWasFocused =
!!focusedId && Array.from({ length: count }, (_, i) => opts.rowId(i)).includes(focusedId)
index = index < 0 ? (delta > 0 ? 0 : count - 1) : (index + delta + count) % count
const row = document.getElementById(opts.rowId(index))
row?.scrollIntoView({ block: 'nearest' })
if (rowWasFocused) row?.focus()
}
return {
get index() {
return index
},
/** Wire to each row's `onMouseEnter`. */
hovered(i: number) {
if (pointerOwns) index = i
},
/** Wire to the list container's `onpointermove`. */
pointerMoved() {
pointerOwns = true
},
/** Wire to the container that holds the search field and the rows, so the keys are
* answered whichever of the two has focus. */
onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
move(e.key === 'ArrowDown' ? 1 : -1)
} else if (
e.key === 'Enter' &&
opts.activateEnterFrom?.includes((e.target as HTMLElement)?.id) &&
index >= 0
) {
e.preventDefault()
opts.onActivate(index)
}
}
}
}
@@ -26,6 +26,10 @@
* and clicks "outside" the child would otherwise propagate * and clicks "outside" the child would otherwise propagate
* here and close the underlying modal. */ * here and close the underlying modal. */
closeOnOutsideClick?: boolean closeOnOutsideClick?: boolean
/** Close on Escape. Default true. Every open modal listens on the
* window, so a stacked pair would both close on one press; set it
* false on the underlying modal while its child is up. */
closeOnEscape?: boolean
/** Wider side padding and a lighter title, for a dialog whose body is a form rather /** Wider side padding and a lighter title, for a dialog whose body is a form rather
* than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */ * than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */
formStyling?: boolean formStyling?: boolean
@@ -46,6 +50,7 @@
fixedHeight = 'md', fixedHeight = 'md',
contentClasses = '', contentClasses = '',
closeOnOutsideClick = true, closeOnOutsideClick = true,
closeOnEscape = true,
formStyling = false, formStyling = false,
headerLeft, headerLeft,
headerRight, headerRight,
@@ -80,7 +85,7 @@
} }
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (!isOpen) return if (!isOpen || !closeOnEscape) return
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
@@ -26,6 +26,9 @@
* around this owns it, and a page component that swallowed it would stop the dialog from * around this owns it, and a page component that swallowed it would stop the dialog from
* closing. Without this prop the pages are still navigable, just not from the keyboard — * closing. Without this prop the pages are still navigable, just not from the keyboard —
* the caller owns `current` either way. * the caller owns `current` either way.
*
* A host that stays mounted while hidden must withhold it while hidden: the arrows are
* answered at `window`, so a parked instance would take the key off the visible one.
*/ */
onNavigate?: (key: string) => void onNavigate?: (key: string) => void
/** /**
@@ -80,6 +83,9 @@
function onKeydown(event: KeyboardEvent) { function onKeydown(event: KeyboardEvent) {
if (!onNavigate || !listening() || event.metaKey || event.ctrlKey || event.altKey) return if (!onNavigate || !listening() || event.metaKey || event.ctrlKey || event.altKey) return
// A control on the page that already answered the key keeps it: the arrows move focus
// inside a toggle group, a menu, a slider, and those handlers run before this one.
if (event.defaultPrevented) return
if (!ownsKeyboard(event.target)) return if (!ownsKeyboard(event.target)) return
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0 const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
if (step === 0) return if (step === 0) return
@@ -2,6 +2,7 @@
import type { ComponentType } from 'svelte' import type { ComponentType } from 'svelte'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import Button from '$lib/components/common/button/Button.svelte' import Button from '$lib/components/common/button/Button.svelte'
import { arrowTabNav } from '$lib/attachments/arrowTabNav'
import EEOnly from '$lib/components/EEOnly.svelte' import EEOnly from '$lib/components/EEOnly.svelte'
import { enterpriseLicense } from '$lib/stores' import { enterpriseLicense } from '$lib/stores'
@@ -32,7 +33,10 @@
let { groups, selectedId, onNavigate, class: className = '' }: Props = $props() let { groups, selectedId, onNavigate, class: className = '' }: Props = $props()
</script> </script>
<div class={twMerge('flex flex-col gap-6', className)}> <!-- Up/Down walks the items, wrapping at the ends. On the wrapper rather than on each
`<nav>` so the arrows cross group boundaries the way reading down the list does; the
Y axis also leaves Left/Right to the paginated dialogs this can sit inside. -->
<div class={twMerge('flex flex-col gap-6', className)} {@attach arrowTabNav()}>
{#each groups as group (group.title)} {#each groups as group (group.title)}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#if group.title} {#if group.title}
@@ -35,8 +35,9 @@
import ChatQuickActions from './ChatQuickActions.svelte' import ChatQuickActions from './ChatQuickActions.svelte'
import ContextUsageIndicator from './ContextUsageIndicator.svelte' import ContextUsageIndicator from './ContextUsageIndicator.svelte'
import AIChatModelSettings from './AIChatModelSettings.svelte' import AIChatModelSettings from './AIChatModelSettings.svelte'
import McpConnections from './McpConnections.svelte' import AssistantSettingsModal from './AssistantSettingsModal.svelte'
import SkillsPicker from './SkillsPicker.svelte' import { SkillsMenu } from './skills/skillsMenu.svelte'
import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte'
import ChatMode from './ChatMode.svelte' import ChatMode from './ChatMode.svelte'
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte' import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
@@ -207,8 +208,11 @@
} = $props() } = $props()
let aiChatInput: AIChatInput | undefined = $state() let aiChatInput: AIChatInput | undefined = $state()
let mcpConnections: McpConnections | undefined = $state() let assistantSettings: AssistantSettingsModal | undefined = $state()
let skillsPicker: SkillsPicker | undefined = $state() // 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'))
const mcpMenu = new McpMenu(aiChatManager, () => assistantSettings?.open('mcp'))
let plusMenuOpen = $state(false) let plusMenuOpen = $state(false)
let editingMessageIndex = $state<number | null>(null) let editingMessageIndex = $state<number | null>(null)
@@ -959,8 +963,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
const closeMenu = () => (plusMenuOpen = false) const closeMenu = () => (plusMenuOpen = false)
const inGlobal = aiChatManager.mode === AIMode.GLOBAL const inGlobal = aiChatManager.mode === AIMode.GLOBAL
const [skillItems, mcpItems] = await Promise.all([ const [skillItems, mcpItems] = await Promise.all([
inGlobal ? skillsPicker?.menuItems(closeMenu) : undefined, inGlobal ? skillsMenu.items(closeMenu) : undefined,
inGlobal ? mcpConnections?.menuItems(closeMenu) : undefined inGlobal ? mcpMenu.items(closeMenu) : undefined
]) ])
return [ return [
{ {
@@ -1143,10 +1147,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<DatatableCreationPolicy /> <DatatableCreationPolicy />
{/if} {/if}
<ContextUsageIndicator /> <ContextUsageIndicator />
<AIChatModelSettings /> <!-- Unconditional: this composer mounts only via `AIChat` ← `SessionWrapper`,
and `sessionRuntime` locks a session to GLOBAL, where the settings
modal's Instructions section owns the prompt entries. -->
<AIChatModelSettings promptSettings={false} />
{#if aiChatManager.mode === AIMode.GLOBAL} {#if aiChatManager.mode === AIMode.GLOBAL}
<SkillsPicker bind:this={skillsPicker} /> <AssistantSettingsModal bind:this={assistantSettings} />
<McpConnections bind:this={mcpConnections} />
{/if} {/if}
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} {#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
@@ -2123,7 +2123,10 @@ export class AIChatManager {
// pipeline surface when a /pipeline editor has registered helpers. Centralized // pipeline surface when a /pipeline editor has registered helpers. Centralized
// so changeMode, refreshGlobalSkills, and setPipelineHelpers stay consistent — // so changeMode, refreshGlobalSkills, and setPipelineHelpers stay consistent —
// each rebuild would otherwise drop the pipeline augmentation the others added. // each rebuild would otherwise drop the pipeline augmentation the others added.
private configureGlobalMode = () => { //
// Public because it is purely local, unlike `changeMode(GLOBAL)`, which also
// fires the three network refreshes.
configureGlobalMode = () => {
const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), { const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), {
previewTools: this.isSessionChat, previewTools: this.isSessionChat,
user: this.globalIdentity, user: this.globalIdentity,
@@ -33,6 +33,13 @@
type ReasoningProviderModel type ReasoningProviderModel
} from '../reasoningRegistry' } from '../reasoningRegistry'
let {
/** Whether this dropdown carries the custom-prompt entries. Off where the surface
* has an assistant settings modal — its Instructions section owns them there, and
* two ways in would drift. The home composer has no such modal, so it keeps them. */
promptSettings = true
}: { promptSettings?: boolean } = $props()
const aiChatManager = getAiChatManager() const aiChatManager = getAiChatManager()
const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai` const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai`
@@ -335,7 +342,9 @@
class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs" class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs"
> >
<!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). --> <!-- Melt submenu: hover-opens and is floating-positioned (flips on screen edges). -->
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} /> {#if promptSettings}
<DropdownSubmenuItem item={paramItems(close)} {builders} meltItem={item} />
{/if}
<div class="my-1 border-t border-border-light"></div> <div class="my-1 border-t border-border-light"></div>
<div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div> <div class="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
@@ -409,21 +418,24 @@
{/snippet} {/snippet}
</DropdownV2> </DropdownV2>
<AIPromptsModal <!-- Only where the entries that open it are rendered. -->
bind:open={modalOpen} {#if promptSettings}
bind:customPrompts <AIPromptsModal
scope={modalScope} bind:open={modalOpen}
modes={[activeMode]} bind:customPrompts
readOnly={modalReadOnly} scope={modalScope}
{readOnlyReason} modes={[activeMode]}
onSave={modalReadOnly ? undefined : save} readOnly={modalReadOnly}
onReset={reset} {readOnlyReason}
{hasChanges} onSave={modalReadOnly ? undefined : save}
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'} onReset={reset}
target="body" {hasChanges}
fixedHeight="sm" title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined} target="body"
/> fixedHeight="sm"
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
/>
{/if}
<style> <style>
/* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range /* Lean reasoning slider: a thin track and a small, borderless accent thumb. Native range
@@ -0,0 +1,158 @@
<!--
@component
The Files & folders section of the assistant settings modal: everything linked to this
chat, whether it is readable, and the way to unlink it.
Attaching happens in the composer — this is where what is already attached is accounted
for, which is the one place the difference between "attached" and "readable" is visible.
-->
<script lang="ts">
import { Button, ListRow, Section } from '$lib/components/common'
import EmptyState from '$lib/components/common/emptyState/EmptyState.svelte'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import { FileText, Folder, Paperclip, Unlink } from 'lucide-svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { attachmentStatusLabel, countReadyAttachments } from './agentContext'
let {
count = $bindable(),
blocksClose = $bindable()
}: {
/** Attachments the assistant can actually read, for the sidebar badge. */
count: number
/** True while the confirmation is up, so the modal leaves the keys to it. */
blocksClose: boolean
} = $props()
const aiChatManager = getAiChatManager()
let folders = $derived(aiChatManager.attachedFiles.folders)
// Files dropped on a past message are listed alongside the session's own: the
// composer's bar hides them because their chip lives on that message, but the
// assistant reads them like any other row.
let files = $derived([
...aiChatManager.attachedFiles.standalone,
...aiChatManager.attachedFiles.messageAttached
])
let messageScoped = $derived(
new Set(aiChatManager.attachedFiles.messageAttached.map((f) => f.id))
)
let total = $derived(folders.length + files.length)
let ready = $derived(countReadyAttachments(folders, files))
let pendingDetach = $state<{ kind: 'file' | 'folder'; name: string } | undefined>(undefined)
$effect(() => {
count = ready
})
$effect(() => {
blocksClose = pendingDetach !== undefined
})
function detach(target: { kind: 'file' | 'folder'; name: string }) {
if (target.kind === 'folder') aiChatManager.attachedFiles.removeFolder(target.name)
else aiChatManager.attachedFiles.removeFile(target.name)
pendingDetach = undefined
}
</script>
<Section
label="Files & folders"
description="Files and folders linked to this chat, which the assistant can open and search."
class="flex flex-col gap-4"
>
{#snippet action()}
{#if total > 0}
<!-- Both numbers when they differ: one count under a heading about what the
assistant can use would hide the rows it cannot open. -->
<span class="shrink-0 text-2xs text-secondary tabular-nums">
{ready === total ? `${total} usable` : `${ready} of ${total} usable`}
</span>
{/if}
{/snippet}
{#if total === 0}
<EmptyState
icon={Paperclip}
title="Nothing attached"
description="Drop a file or a folder on the chat, or use the paperclip in the composer, and the assistant can open and search it."
/>
{:else}
<div class="flex flex-col gap-0.5">
{#each folders as folder (folder.name)}
{#snippet icon()}
<Folder size={16} class="text-tertiary" />
{/snippet}
{#snippet title()}
<span class="truncate leading-5">{folder.name}</span>
{@const label = attachmentStatusLabel(folder.status)}
{#if label}
<span class="shrink-0 text-2xs font-normal text-hint">{label}</span>
{/if}
{/snippet}
{#snippet subtitle()}
{folder.files.length}
{folder.files.length === 1 ? 'file' : 'files'}
{/snippet}
{#snippet trailing()}
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: Unlink }}
iconOnly
title="Unlink folder"
onClick={() => (pendingDetach = { kind: 'folder', name: folder.name })}
/>
{/snippet}
<ListRow {icon} {title} {subtitle} {trailing} />
{/each}
{#each files as file (file.id ?? file.name)}
{#snippet icon()}
<FileText size={16} class="text-tertiary" />
{/snippet}
{#snippet title()}
<span class="truncate leading-5">{file.name}</span>
{@const label = attachmentStatusLabel(file.status)}
{#if label}
<span class="shrink-0 text-2xs font-normal text-hint">{label}</span>
{/if}
{/snippet}
{#snippet subtitle()}
Attached to a message
{/snippet}
{#snippet trailing()}
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: Unlink }}
iconOnly
title="Unlink file"
onClick={() => (pendingDetach = { kind: 'file', name: file.name })}
/>
{/snippet}
<!-- A message-scoped row has no unlink here: those are rebuilt from the
transcript on every load, so `removeFile` refuses them and the chip on the
message is what actually drops one. -->
<ListRow
{icon}
{title}
subtitle={messageScoped.has(file.id) ? subtitle : undefined}
trailing={messageScoped.has(file.id) ? undefined : trailing}
/>
{/each}
</div>
{/if}
</Section>
<ConfirmationModal
open={pendingDetach !== undefined}
title={pendingDetach?.kind === 'folder' ? 'Unlink folder' : 'Unlink file'}
confirmationText="Unlink"
onConfirmed={() => pendingDetach && detach(pendingDetach)}
onCanceled={() => (pendingDetach = undefined)}
>
<span class="text-xs text-primary">
The assistant loses access to <span class="font-semibold">{pendingDetach?.name}</span>. Nothing
is deleted from your disk, and you can attach it again from the composer.
</span>
</ConfirmationModal>
@@ -0,0 +1,354 @@
<!--
@component
The Instructions section of the assistant settings modal: the two blocks of custom
instructions the system prompt carries — the workspace one an admin sets for everyone,
and the user one stored in this browser — a tab each, both editable in place. One Save
writes whichever of the two changed, including the tab that is not on screen.
-->
<script lang="ts">
import { Button, Section, Tab, TabContent, Tabs } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import {
copilotInfo,
getUserCustomPrompts,
setCopilotInfo,
setUserCustomPrompts
} from '$lib/aiStore'
import { WorkspaceService } from '$lib/gen'
import { userStore, type UserExt } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import { Building2, ExternalLink, User } from 'lucide-svelte'
import { untrack } from 'svelte'
import { getAiChatManager } from './aiChatManagerContext'
let {
ws,
active,
blocksClose = $bindable()
}: {
/** The workspace the chat operates on, which is not always the one on screen. */
ws: string
/** Whether this is the panel on screen. The workspace half costs a settings read,
* so it is fetched when someone looks at instructions rather than on every open
* of the modal. */
active: boolean
/** True while this section is in the middle of something the modal must not
* close under — here, instructions typed and not yet saved. */
blocksClose: boolean
} = $props()
const MAX_PROMPT_LENGTH = 5000
const aiChatManager = getAiChatManager()
// The section acts on `ws`, which in a session is not the nav workspace, so the link
// has to name it or it opens settings the user may not administer.
let aiSettingsHref = $derived(`${base}/workspace_settings?workspace=${ws}&tab=ai`)
let mode = $derived(aiChatManager.mode)
let tab = $state<'workspace' | 'user'>('workspace')
let note = $derived(
tab === 'workspace'
? `Applies to everyone in ${ws}.`
: 'Stored in this browser and sent in every workspace, so they follow you rather than the workspace.'
)
// `$userStore.is_admin` is the role in the nav workspace, not necessarily in `ws`, so
// the resolved role is keyed to the workspace it was read for, and an unresolved one
// reads as no admin: offering the field and taking it away on resolve would discard
// whatever was typed in between. Superadmin holds everywhere.
let targetRole = $state<{ workspace: string; user: UserExt | undefined } | undefined>(undefined)
let roleRead = $derived(targetRole?.workspace === ws ? targetRole : undefined)
let navIsTarget = $derived($userStore?.workspace_id === ws)
let roleForTarget = $derived(roleRead?.user)
let roleResolved = $derived(roleRead !== undefined || navIsTarget)
// A read that came back with no user: read-only like a non-admin, but said differently,
// since a failed lookup is not evidence of the role it failed to read.
let roleUnknown = $derived(roleRead !== undefined && !roleRead.user && !navIsTarget)
let isAdmin = $derived(
Boolean(
$userStore?.is_super_admin ||
(roleForTarget ? roleForTarget.is_admin : navIsTarget && $userStore?.is_admin)
)
)
// A session whose fork is still staged has no workspace of its own yet, so `ws`
// resolves to the PARENT. Saving here would edit the live parent's shared AI config,
// which every chat still in it would then be given.
function pendingForkParent(): string | undefined {
return aiChatManager.sessionContextResolver?.()?.pendingForkOf
}
let forkPending = $derived(pendingForkParent() !== undefined)
// What is on screen, against what was loaded. Two pairs rather than one: the halves
// are stored in different places and save through different calls, and only the one
// that changed should be written.
let workspaceDraft = $state('')
let workspaceSaved = $state('')
let userDraft = $state('')
let userSaved = $state('')
let loading = $state(false)
let saving = $state(false)
// True when the workspace has no AI providers of its own (it uses instance defaults).
// In that case the backend never makes workspace custom_prompts effective, so a saved
// workspace prompt would be dead config — mirror the settings page and show it read-only.
let workspaceMissingProviders = $state(false)
let workspaceReadOnly = $derived(!isAdmin || workspaceMissingProviders || forkPending)
let readOnlyReason = $derived(
forkPending
? `This session has not created its workspace yet, so these would be saved to "${pendingForkParent()}" and given to every chat already in it. Send a message first.`
: !roleResolved
? `Checking your access to ${ws}.`
: roleUnknown
? `Could not read your access to ${ws}. Reopen this section to try again.`
: !isAdmin
? 'Only workspace admins can edit the workspace instructions.'
: 'This workspace uses instance AI defaults, so a workspace prompt would have no effect. Configure workspace AI providers in AI settings first.'
)
let workspaceChanged = $derived(!workspaceReadOnly && workspaceDraft !== workspaceSaved)
let userChanged = $derived(userDraft !== userSaved)
let dirty = $derived(workspaceChanged || userChanged)
$effect(() => {
blocksClose = dirty
})
// Loaded on the first look and again on a workspace switch: B's instructions must not
// be saved over A's. A reload drops a draft, so it waits until one is saved or
// reverted — hence `dirty` tracked, and `loadedWorkspace` to pick the wait up against
// whatever `ws` is by then (a staged fork commits into a workspace of its own).
let loadSeq = 0
let loadedWorkspace: string | undefined = undefined
$effect(() => {
const target = ws
const shown = active
const clean = !dirty
untrack(() => {
// Parked: read again on the next visit, since the workspace half is editable
// from the settings page too.
if (!shown) {
loadedWorkspace = undefined
return
}
if (!target || !clean || target === loadedWorkspace) return
loadedWorkspace = target
void load(target)
})
})
async function load(target: string) {
const seq = ++loadSeq
loading = true
try {
const resolved = await getUserExt(target).catch(() => undefined)
if (seq !== loadSeq) return
targetRole = { workspace: target, user: resolved }
const user = getUserCustomPrompts()[mode] ?? ''
// Seeded from the same source `saveWorkspace` writes to (the raw workspace
// ai_config), which also says whether the workspace has providers of its own.
// Non-admins cannot read raw settings, so they get the effective prompt.
let workspace = $copilotInfo.customPrompts?.[mode] ?? ''
let missingProviders = false
if (isAdmin) {
try {
const settings = await WorkspaceService.getSettings({ workspace: target })
missingProviders = Object.keys(settings?.ai_config?.providers ?? {}).length === 0
workspace = settings?.ai_config?.custom_prompts?.[mode] ?? ''
} catch (err) {
sendUserToast(`Failed to load workspace AI prompt: ${err}`, true)
}
}
if (seq !== loadSeq) return
workspaceMissingProviders = missingProviders
// Read before the assignments below move the baseline they compare against.
// Each half is then seeded only while it still holds what it was loaded with:
// the effect that starts this checks `dirty` before the request, not after it,
// so an admin who opens the tab and types straight away would otherwise have
// the settings response land on top of the text they are in the middle of.
const keepWorkspaceDraft = workspaceChanged
const keepUserDraft = userChanged
workspaceSaved = workspace
if (!keepWorkspaceDraft) workspaceDraft = workspace
userSaved = user
if (!keepUserDraft) userDraft = user
} finally {
if (seq === loadSeq) loading = false
}
}
/** Escape puts the fields back rather than closing the modal: `blocksClose` holds the
* modal shut while there is unsaved text, so this is the way out of a draft, and a
* second press then closes. */
function onKeydown(event: KeyboardEvent) {
// Only while this is the section on screen: every section stays mounted, so an
// Escape meant for another one would revert these drafts with nothing visible
// to say that it had.
if (!active || event.key !== 'Escape' || !dirty) return
event.preventDefault()
event.stopPropagation()
workspaceDraft = workspaceSaved
userDraft = userSaved
}
async function save() {
saving = true
try {
if (userChanged) saveUser(userDraft.trim())
if (workspaceChanged) await saveWorkspace(workspaceDraft.trim())
} finally {
saving = false
}
}
function saveUser(value: string) {
const prompts = getUserCustomPrompts()
if (value) {
prompts[mode] = value
} else {
delete prompts[mode]
}
setUserCustomPrompts(prompts)
// These live in localStorage, which nothing observes, so the chat is told rather
// than left to pick them up on its next send. `update_user_instructions` already
// rebuilds this way when the assistant edits the same block.
aiChatManager.rebuildGlobalSystemMessage()
userSaved = value
userDraft = value
sendUserToast('User instructions were saved')
}
async function saveWorkspace(value: string) {
const parent = pendingForkParent()
if (parent !== undefined) {
sendUserToast(
`This session has not created its workspace yet, so the instructions would be saved to "${parent}". Send a message first.`,
true
)
return
}
// Pinned across both awaits: the read and the write have to land on one workspace.
const target = ws
try {
// Saving prompts requires a full ai_config round-trip; fetch the current config
// so we don't clobber providers/models/etc.
const settings = await WorkspaceService.getSettings({ workspace: target })
const config = settings.ai_config ?? {}
const custom_prompts = { ...(config.custom_prompts ?? {}) }
if (value) {
custom_prompts[mode] = value
} else {
delete custom_prompts[mode]
}
const response = await WorkspaceService.editCopilotConfig({
workspace: target,
requestBody: { ...config, custom_prompts }
})
setCopilotInfo(response.effective_ai_config)
workspaceSaved = value
workspaceDraft = value
sendUserToast('Workspace instructions were saved')
} catch (err) {
// The field keeps what was typed, so the save can be retried.
sendUserToast(`Failed to save workspace AI prompt: ${err}`, true)
}
}
</script>
<svelte:window onkeydown={onKeydown} />
{#snippet field(p: { value: string; readOnly: boolean; onInput: (v: string) => void })}
<div class="flex flex-col gap-1">
<TextInput
value={p.value}
underlyingInputEl="textarea"
size="sm"
class="min-h-24 resize-y"
inputProps={{
placeholder: p.readOnly ? '' : 'Anything the assistant should always keep in mind',
rows: 4,
maxlength: MAX_PROMPT_LENGTH,
// Also while saving: the write spans two requests and its success path puts the
// submitted value back, so text typed in between would be swallowed. `load`
// keeps a newer draft instead, because it starts on its own and must not block
// someone who opened the tab to type.
readonly: p.readOnly || saving,
oninput: (e) => p.onInput(e.currentTarget.value)
}}
/>
{#if !p.readOnly}
<span class="self-end text-2xs text-hint">
{p.value.length}/{MAX_PROMPT_LENGTH} characters
</span>
{/if}
</div>
{/snippet}
<Section
label="Instructions"
description="Text added to every system prompt in this workspace, on top of the assistant's own. Both blocks are sent, the workspace one first."
class="flex flex-col gap-4"
>
{#snippet action()}
<div class="flex items-center gap-2 shrink-0">
{#if isAdmin}
<Button
href={aiSettingsHref}
target="_blank"
variant="subtle"
unifiedSize="sm"
endIcon={{ icon: ExternalLink }}
>
AI settings
</Button>
{/if}
<Button
variant="accent"
unifiedSize="sm"
disabled={!dirty || saving || loading}
onClick={save}
>
Save
</Button>
</div>
{/snippet}
<Tabs values={['workspace', 'user']} bind:selected={tab}>
<Tab value="workspace" label="Workspace" icon={Building2} />
<Tab value="user" label="User (you)" icon={User} />
{#snippet content()}
<!-- The note belongs to the panel rather than to either tab: one element that
follows the selection, so switching tabs does not rebuild it and the space
under the tab row is the same on both. The wrapper carries no padding of its
own — `Tabs` renders the row and this content as two roots, so the Section's
`gap-4` is already the space between them. -->
<div class="flex flex-col gap-2">
<Description>{note}</Description>
{#if workspaceReadOnly && tab === 'workspace'}
<Alert type="info" title="These are read-only for you" size="xs">
{readOnlyReason}
</Alert>
{/if}
<!-- `alwaysMounted`: Save writes whichever half changed, including the tab that
is not on screen, so a field left with unsaved text has to go on holding it. -->
<TabContent value="workspace" alwaysMounted>
{@render field({
value: workspaceDraft,
readOnly: workspaceReadOnly,
onInput: (v) => (workspaceDraft = v)
})}
</TabContent>
<TabContent value="user" alwaysMounted>
{@render field({
value: userDraft,
readOnly: false,
onInput: (v) => (userDraft = v)
})}
</TabContent>
</div>
{/snippet}
</Tabs>
</Section>
@@ -0,0 +1,632 @@
<!--
@component
The MCP connections section of the assistant settings modal: the form that connects
an external MCP server to this chat, and the servers already connected, each with the
switch that decides whether this chat carries its tools.
-->
<script lang="ts">
import { Button, ListRow, Section } from '$lib/components/common'
import EmptyState from '$lib/components/common/emptyState/EmptyState.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import McpConnect from '$lib/components/mcp/McpConnect.svelte'
import ResourceEditor from '$lib/components/ResourceEditor.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { isMcpEnabled, setMcpEnabled } from '$lib/components/mcp/enabledServers'
import { loadProviderIcon } from '$lib/components/mcp/providerIcon'
import {
cachedProviderKey,
forgetProviderKey,
rememberProviderKey
} from '$lib/components/mcp/iconCache'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import type { Component } from 'svelte'
import { ResourceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { ArrowLeft, Loader2, Pencil, Plug, Plus, Trash2 } from 'lucide-svelte'
import { draftValuesEqual } from '$lib/userDraft.svelte'
import { untrack } from 'svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { clearMcpToolsCache } from './global/mcpTools'
let {
ws,
active,
count = $bindable(),
blocksClose = $bindable()
}: {
/** The workspace the chat operates on, which is not always the one on screen. */
ws: string
/** Whether this is the panel on screen. Gates the connect page's build, which
* costs a read of the instance OAuth connects — not worth paying for on a modal
* opened for one of the other three sections. */
active: boolean
/** Number of connected servers, for the sidebar badge. */
count: number
/** True while this section is in the middle of something the modal must not
* close under: its confirmation, or the connect form holding a half-filled
* connection. */
blocksClose: boolean
} = $props()
const aiChatManager = getAiChatManager()
// A session whose fork is still staged has no workspace of its own yet, so `ws`
// resolves to the PARENT. Connecting or editing here would write the resource and
// its token into the live parent, and a switch would be stored under it and quietly
// stop applying the moment the first send commits the fork. Read at use, not once:
// the fork commits mid-session.
function pendingForkParent(): string | undefined {
return aiChatManager.sessionContextResolver?.()?.pendingForkOf
}
let forkPending = $state(false)
function refreshForkPending() {
forkPending = pendingForkParent() !== undefined
}
/** Guards every mutating action. Returns true when the caller must not proceed. */
function blockedByPendingFork(): boolean {
const parent = pendingForkParent()
if (parent === undefined) return false
refreshForkPending()
sendUserToast(
`This session has not created its workspace yet, so the connection would be written to "${parent}" instead. Send a message first.`,
true
)
return true
}
let servers = $state<
{
path: string
description?: string
editedAt?: string
enabled: boolean
icon?: Component<any>
}[]
>([])
let loading = $state(false)
let loadError = $state<string | undefined>(undefined)
let pendingDelete = $state<string | undefined>(undefined)
// A row's overflow menu is portaled out of the modal, so a click on one of its items
// is a click outside the modal. Tracking it here keeps the modal from closing under
// the action the item is about to run.
let rowMenuOpen = $state<Record<string, boolean>>({})
// The connect form is the second page of this panel, not a surface over it.
let connectOpen = $state(false)
// Bumped as the connect page is left, which is what clears the form for the next
// connection: the page stays mounted once built, so nothing else would. Bumped on
// the way out rather than on the way in so the rebuild happens while the page is
// parked and invisible, leaving every visit an already-built form.
let connectSeq = $state(0)
// The row being edited, on the third page. `ResourceEditor` is the same editor the
// resource drawer opens, so a connection is edited here the way it is edited
// anywhere else — url, token, description, path.
// Kept when the detail page is left so the Right arrow steps back into it.
let editing = $state<{ path: string; enabled: boolean } | undefined>(undefined)
let detailOpen = $state(false)
// The path the editor holds, which is not `editing.path` once someone renames it.
let editingPath = $state('')
let canSaveEditing = $state(false)
let resourceEditor: ResourceEditor | undefined = $state(undefined)
// What the form holds, which is what Save is measured against — not the stored value
// the editor loaded. The schema form fills in a value for every property the resource
// type declares, so an `mcp` resource saved without `headers` is holding
// `headers: null` by the time it is on screen, and measuring against the stored value
// would call that an edit the moment a connection is opened. The trigger editors take
// their baseline from their own form for the same reason.
//
// It follows the form until the form is first focused, rather than being taken at a
// moment judged to be after the fields have filled themselves in: those writes land
// over several flushes and a snapshot timed against them is a guess, while nothing
// here can be edited without focus reaching the form first.
let editingBaseline = $state<unknown>(undefined)
let editingTouched = $state(false)
$effect(() => {
const editor: ResourceEditor | undefined = resourceEditor
// Snapshotting is also the deep read that makes this run again on every field.
const settled = editor === undefined ? undefined : $state.snapshot(editor.localDraftCurrent())
untrack(() => {
if (editingTouched || settled === undefined) return
editingBaseline = settled
})
})
let editingChanged = $derived.by(() => {
const editor: ResourceEditor | undefined = resourceEditor
return !draftValuesEqual(editor?.localDraftCurrent(), editingBaseline)
})
// Bumped per visit: `ResourceEditor` reads its path once, on mount, and this page
// stays mounted, so without a remount the second server opened would be the first.
let detailSeq = $state(0)
let page = $derived(connectOpen ? 'connect' : detailOpen ? 'detail' : 'list')
$effect(() => {
count = servers.length
})
$effect(() => {
blocksClose =
pendingDelete !== undefined ||
connectOpen ||
detailOpen ||
Object.values(rowMenuOpen).some(Boolean)
})
// Parked with the section: a page left open behind another section would go on
// reporting `blocksClose`, and the modal would refuse to close with nothing on
// screen explaining why. Set directly rather than through `closeConnect`, whose
// rebuild would throw away a half-filled connect form.
$effect(() => {
if (!active) {
detailOpen = false
connectOpen = false
}
})
/** Escape steps back to the list rather than closing the whole modal: `blocksClose`
* stops the modal's own handler, so this is the only thing left to answer the key. */
function onKeydown(event: KeyboardEvent) {
// Every section stays mounted while the modal is open, and `stopPropagation`
// does nothing between listeners on `window`: without this, a key aimed at the
// section on screen is answered by the four behind it too.
if (!active || event.key !== 'Escape' || page === 'list' || pendingDelete !== undefined) return
event.preventDefault()
event.stopPropagation()
if (connectOpen) closeConnect()
else closeDetail()
}
function openConnect() {
if (blockedByPendingFork()) return
connectOpen = true
}
function closeConnect() {
connectOpen = false
connectSeq++
}
function openServer(server: { path: string; enabled: boolean }) {
editing = { path: server.path, enabled: server.enabled }
editingPath = server.path
// Dropped with the editor it was taken from: the next one settles on its own
// connection, and comparing against the previous one's would call every field
// an edit.
editingBaseline = undefined
editingTouched = false
detailSeq++
detailOpen = true
}
function closeDetail() {
detailOpen = false
}
/** Left and Right step between the pages, which is `PagedContent` answering the
* arrows once it is given this. Each forward step only goes somewhere it has
* something to show: the detail page holds a connection only once one was opened.
*
* The three pages are a strip and the arrows walk it by position, so stepping back
* from the connect form asks for the detail page. Both of those are levels below the
* list rather than a sequence, so backwards out of either lands on the list. */
function navigate(key: string) {
if (key === 'list' || connectOpen) {
connectOpen = false
detailOpen = false
} else if (key === 'connect') {
openConnect()
} else if (editing) {
detailOpen = true
}
}
async function saveEditing() {
if (blockedByPendingFork()) return
const server = editing
if (!server) return
// Pinned alongside the row, like `toggle` and `deleteConnection`: the enablement
// writes below run after the save returns, and must land under the workspace the
// connection was edited in rather than whichever one is current by then.
const target = ws
// A failed save leaves the connection exactly as it was, so none of the
// bookkeeping below may run: moving the enablement then would turn a server
// that still exists off, and turn on a path that was never created.
if (!(await resourceEditor?.save())) return
// Enablement is keyed by path, so a rename would leave the switch on the path
// that no longer exists and the server itself off.
if (editingPath && editingPath !== server.path) {
setMcpEnabled(target, server.path, false)
forgetProviderKey(target, server.path)
setMcpEnabled(target, editingPath, server.enabled)
}
closeDetail()
await refresh(target)
}
// Rows describe one workspace. A switch while the section is open must not leave
// A's rows on screen while the actions below target B: same path, different
// server, and a delete would remove the wrong one.
let loadSeq = 0
$effect(() => {
const target = ws
untrack(() => {
servers = []
pendingDelete = undefined
// Back to the list too: the editor holds one workspace's resource, and the
// path it is on names a different server in the workspace switched to.
editing = undefined
detailOpen = false
connectOpen = false
void loadServers(target)
})
})
async function loadServers(target = ws) {
refreshForkPending()
// Checked before the sequence is taken, not only after the await: a stale
// action calling refresh(A) would otherwise claim the newest sequence and make
// the legitimate load for B discard its own result.
if (!target || target !== ws) return
const seq = ++loadSeq
loading = true
loadError = undefined
try {
const resources = await ResourceService.listResource({
workspace: target,
resourceType: 'mcp',
perPage: 100
})
if (seq !== loadSeq) return
servers = resources.map((r) => ({
path: r.path,
description: r.description,
editedAt: r.edited_at,
enabled: isMcpEnabled(target, r.path)
}))
// Seeded rather than filled by the bindings: an unset entry would hand
// DropdownV2 an `undefined` open state instead of a closed one.
rowMenuOpen = Object.fromEntries(resources.map((r) => [r.path, false]))
void loadIcons(target, seq)
} catch (e) {
if (seq !== loadSeq) return
// Without this the section would render the empty state, which reads as
// "you have no connections" rather than "we could not load them".
loadError = e.body ?? e.message
} finally {
if (seq === loadSeq) loading = false
}
}
async function toggle(path: string, enabled: boolean) {
if (blockedByPendingFork()) return
// Pinned for the whole call: the selection is stored per workspace, and the
// refresh below must not hand these servers to a chat that has since moved on.
const target = ws
// Local preference only: nothing to re-read from the API, and the cached
// tool lists stay valid because the servers are unchanged. Checked, like the
// skills switch: a refused write leaves the chat carrying a different set than
// the switch shows, and nothing else would ever say so.
if (!setMcpEnabled(target, path, enabled)) {
sendUserToast('Could not save the selection for this account.', true)
return
}
const server = servers.find((s) => s.path === path)
if (server) server.enabled = enabled
if (target !== ws) return
await aiChatManager.refreshMcpServers(target)
}
// Deleting a resource also deletes every variable its value references, and an
// mcp resource's token is usually the credential of the resource it was created
// from (the github one). Drop the reference first so deleting the connection can
// never destroy a credential something else still uses; the variable is left for
// the user to remove from the Variables page.
async function deleteConnection(path: string) {
if (blockedByPendingFork()) return
// Pinned for the whole sequence: a switch midway would strip and delete the
// resource that happens to share this path in the workspace switched to.
const target = ws
try {
const resource = await ResourceService.getResource({
workspace: target,
path
})
const { token: _token, ...withoutToken } = (resource.value ?? {}) as Record<string, unknown>
await ResourceService.updateResource({
workspace: target,
path,
requestBody: { value: withoutToken }
})
await ResourceService.deleteResource({ workspace: target, path })
// A later resource at this path is a different server; it must be turned
// on deliberately rather than inherit this one's enablement.
setMcpEnabled(target, path, false)
forgetProviderKey(target, path)
sendUserToast(`Deleted ${path}. Its token variable was kept.`)
await refresh(target)
} catch (e) {
sendUserToast(`Failed to delete ${path}: ${e.body ?? e.message}`, true)
} finally {
pendingDelete = undefined
}
}
// A row whose provider is already cached paints from the cache; the rest cost
// one read each, and a long list stops asking rather than firing a request
// storm at a screen nobody is reading that far down.
const MAX_ICON_LOOKUPS = 20
async function loadIcons(target: string, seq: number) {
let lookups = 0
await Promise.all(
servers.map(async (server) => {
let key = cachedProviderKey(target, server.path, server.editedAt)
if (key === undefined) {
if (lookups >= MAX_ICON_LOOKUPS) return
lookups++
try {
const resource = await ResourceService.getResource({
workspace: target,
path: server.path
})
key = rememberProviderKey(
target,
server.path,
(resource.value as { url?: unknown } | undefined)?.url,
server.editedAt
)
} catch {
return
}
}
const icon = await loadProviderIcon(key)
if (seq !== loadSeq) return
server.icon = icon
})
)
}
async function refresh(target = ws) {
// A path can be reconnected to a different server, so the cached tool list
// (and the readOnlyHint the confirmation gate reads) must not survive.
clearMcpToolsCache()
await loadServers(target)
// `refreshMcpServers` blanks the list when the workspace it is handed is not
// the one the chat is on, so a refresh landing after a switch would take B's
// servers out of the prompt entirely.
if (target !== ws) return
// Re-register the chat's MCP tools so a connection made here is usable in
// the next message without a reload.
await aiChatManager.refreshMcpServers(target)
}
</script>
<svelte:window onkeydown={onKeydown} />
<!-- The list, a connection and the connect form are levels of one panel, so moving
between them slides rather than cuts — the same shape the Skills panel uses for
its editor. Warmed once this panel is on screen so the connect form is built
before the click rather than inside the transition; the detail page holds a
`ResourceEditor` for one path and only builds once a row is opened. -->
<PagedContent
warm={active}
class="grow min-h-0"
current={page}
onNavigate={active ? navigate : undefined}
pages={[
{ key: 'list', content: listPage },
{ key: 'detail', content: detailPage },
{ key: 'connect', content: connectPage }
]}
/>
{#snippet listPage()}
<div class="grow min-h-0 overflow-y-auto pr-2">
<Section
label="MCP connections"
description="External MCP servers this chat can call. Their tools run with your own credentials, so the chat can only reach what you can."
class="flex flex-col gap-4"
>
{#snippet action()}
<Button
unifiedSize="sm"
variant="accent"
startIcon={{ icon: Plus }}
disabled={forkPending}
onClick={openConnect}
>
Connect a server
</Button>
{/snippet}
{#if forkPending}
<Alert type="info" title="This session has no workspace yet" size="xs" class="mb-4">
Connections are read-only until the first message creates this session's fork. Connecting
or selecting one now would apply to the parent workspace and stop applying once the fork
is created.
</Alert>
{/if}
{#if loading}
<div class="flex justify-center p-4"><Loader2 class="animate-spin" /></div>
{:else if loadError}
<div class="text-xs text-red-600 dark:text-red-400">
Failed to load MCP connections: {loadError}
</div>
{:else if servers.length === 0}
<EmptyState
icon={Plug}
title="No MCP server connected"
description="Connect an external MCP server and the chat can call its tools with your own credentials."
action={{
label: 'Connect a server',
icon: Plus,
onClick: openConnect,
disabled: forkPending
}}
/>
{:else}
<div class="flex flex-col gap-0.5">
{#each servers as server (server.path)}
{#snippet icon()}
{#if server.icon}
{@const Icon = server.icon}
<Icon width="16px" height="16px" />
{:else}
<Plug size={16} class="text-tertiary" />
{/if}
{/snippet}
{#snippet title()}
<span class="truncate leading-5">{server.path}</span>
{/snippet}
{#snippet subtitle()}{server.description}{/snippet}
{#snippet trailing()}
<Toggle
size="sm"
disabled={forkPending}
checked={server.enabled}
on:change={async (e) => await toggle(server.path, e.detail)}
/>
<DropdownV2
size="sm"
bind:open={rowMenuOpen[server.path]}
items={[
{
displayName: 'Manage connection',
icon: Pencil,
action: () => openServer(server)
},
{
displayName: 'Delete',
icon: Trash2,
type: 'delete',
disabled: forkPending,
action: () => (pendingDelete = server.path)
}
]}
/>
{/snippet}
<ListRow
{icon}
{title}
{trailing}
subtitle={server.description ? subtitle : undefined}
onClick={() => openServer(server)}
/>
{/each}
</div>
{/if}
</Section>
</div>
{/snippet}
{#snippet detailPage()}
<div class="grow min-h-0 overflow-y-auto pr-2">
<!-- Sticky so the way back is always one click away, however far the page scrolls. -->
<div class="flex sticky top-0 z-10 bg-surface pb-1">
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: ArrowLeft }}
btnClasses="text-secondary"
onClick={closeDetail}
>
MCP connections
</Button>
</div>
<!-- `headerClass` keeps a long path on one line: the Save button shares the header
row and would otherwise wrap the title under itself. -->
<!-- Titled by the path the editor holds rather than the row's, so a rename is
visible as it is typed and the header does not empty out while the page
slides away. -->
<Section label={editingPath} wrapperClass="mt-1" headerClass="min-w-0 truncate pr-2 font-mono">
{#snippet action()}
<div class="flex justify-end shrink-0">
<Button
variant="accent"
unifiedSize="sm"
disabled={!canSaveEditing || !editingChanged}
onClick={saveEditing}
>
Save
</Button>
</div>
{/snippet}
<!-- Freezes the baseline above: from the first focus on, what the form holds is
the user's doing rather than its own. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onfocusin={() => (editingTouched = true)}>
{#key detailSeq}
{#if detailSeq > 0}
<!-- `editingPath` is seeded with the row's path before this remounts, which
is how the editor is told which resource to load: it reads its path once,
on mount, and reports a rename back through the same binding. -->
<ResourceEditor
bind:this={resourceEditor}
bind:canSave={canSaveEditing}
bind:path={editingPath}
workspace={ws}
resource_type="mcp"
/>
{/if}
{/key}
</div>
</Section>
</div>
{/snippet}
{#snippet connectPage()}
<!-- The form takes the panel over rather than opening on top of it: a form stacked
on the settings modal leaves two surfaces arguing over which one a click or an
Escape belongs to. `McpConnect` carries its own heading, so there is no Section
around it. -->
<div class="grow min-h-0 overflow-y-auto pr-2">
<!-- Sticky so the way back is always one click away, however far the page scrolls. -->
<div class="flex sticky top-0 z-10 bg-surface pb-1">
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: ArrowLeft }}
btnClasses="text-secondary"
onClick={closeConnect}
>
MCP connections
</Button>
</div>
<div class="mt-1">
{#key connectSeq}
<McpConnect
bordered={false}
workspace={ws}
onConnected={async (connectedWs, path) => {
// Connecting one is the act of choosing it, and it is keyed on where
// it was created rather than on what is on screen now: a switch
// during the popup would otherwise enable the path in a workspace
// that has no such connection.
if (!setMcpEnabled(connectedWs, path, true)) {
sendUserToast(`Connected ${path}, but could not turn it on. Toggle it here.`, true)
}
closeConnect()
await refresh()
}}
/>
{/key}
</div>
</div>
{/snippet}
<ConfirmationModal
open={pendingDelete !== undefined}
title="Delete MCP connection"
confirmationText="Delete"
onConfirmed={() => {
if (pendingDelete) void deleteConnection(pendingDelete)
}}
onCanceled={() => (pendingDelete = undefined)}
>
<span class="text-xs text-primary">
This deletes the resource at <span class="font-semibold">{pendingDelete}</span>, so the chat and
any flow pointing at it lose the server. Its token variable is kept. To stop this chat from
using the server without deleting it, turn its switch off instead.
</span>
</ConfirmationModal>
@@ -0,0 +1,238 @@
<!--
@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 { workspaceStore } from '$lib/stores'
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'
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 = $workspaceStore
return aiChatManager.operatingWorkspace ?? active ?? ''
})
// Exactly what the chat loop sends — plan mode's transition tool is registered
// alongside `tools` there, so listing only `tools` would under-report.
let tools = $derived(summarizeTools([...aiChatManager.tools, ...aiChatManager.planMode.tools]))
// `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()
}
}
</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>
@@ -0,0 +1,212 @@
<!--
@component
The Tools section of the assistant settings modal: every tool definition this chat
sends with each turn. The list carries the model-facing name and description; opening
one shows the description in full and the arguments it takes. Read-only — tools are not
individually switchable, they follow the mode and the connected servers.
-->
<script lang="ts">
import { Button, ListRow, Section } from '$lib/components/common'
import { useListHighlight } from '$lib/components/common/listRow/listHighlight.svelte'
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
import SchemaViewer from '$lib/components/SchemaViewer.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { ArrowLeft } from 'lucide-svelte'
import type { ToolSummary } from './agentContext'
let {
tools,
active,
blocksClose = $bindable()
}: {
tools: ToolSummary[]
/** Whether this is the panel on screen. Gates the detail page's build, which pulls
* in the schema table and its syntax highlighter. */
active: boolean
/** True while the detail page is open, so the modal leaves Escape to this section. */
blocksClose: boolean
} = $props()
type Marked = ToolSummary & { marked?: string }
let filter = $state('')
// The same fuzzy search the home list and the pickers use, run twice: `marked` covers
// whatever the haystack was, and the name and the description are two fields on the
// row rather than one string, so each needs a pass of its own to be highlighted.
let nameHits = $state<Marked[] | undefined>(undefined)
let descriptionHits = $state<Marked[] | undefined>(undefined)
// The tool the detail page shows, kept when the page is left so the Right arrow
// steps back into it.
let selected = $state<ToolSummary | undefined>(undefined)
let detailOpen = $state(false)
const SEARCH_INPUT_ID = 'assistant-tools-search'
const rowDomId = (index: number) => `assistant-tool-row-${index}`
let searching = $derived(filter.trim().length > 0)
// Name matches first, then the tools that only matched on what they do. Within each
// half the order is the one uFuzzy ranked.
let rows: { tool: ToolSummary; name?: string; description?: string }[] = $derived.by(() => {
if (!searching) return tools.map((tool) => ({ tool }))
const byName = nameHits ?? []
const named = new Set(byName.map((t) => t.name))
return [
...byName.map((t) => ({ tool: t, name: t.marked })),
...(descriptionHits ?? [])
.filter((t) => !named.has(t.name))
.map((t) => ({ tool: t, description: t.marked }))
]
})
// The arrows and Enter are answered while focus stays in the search field, the way the
// resource-type picker does it: type a few letters, step down the hits, open one.
const highlight = useListHighlight({
count: () => rows.length,
rowId: rowDomId,
// The top hit while searching — uFuzzy already ranked it there — and nothing lit
// once the field is cleared, when every tool is on screen and none is the answer.
restingIndex: () => (searching && rows.length > 0 ? 0 : -1),
onActivate: (index) => open(rows[index]?.tool),
activateEnterFrom: [SEARCH_INPUT_ID]
})
function open(tool: ToolSummary | undefined) {
if (!tool) return
selected = tool
detailOpen = true
}
$effect(() => {
blocksClose = detailOpen
})
// Parked with the section: a detail page left open behind another section would go
// on reporting `blocksClose`, and the modal would refuse to close with nothing on
// screen explaining why.
$effect(() => {
if (!active) detailOpen = false
})
/** Escape steps back to the list rather than closing the whole modal: `blocksClose`
* stops the modal's own handler, so this is the only thing left to answer the key. */
function onKeydown(event: KeyboardEvent) {
// Every section stays mounted while the modal is open, and `stopPropagation`
// does nothing between listeners on `window`: without this, a key aimed at the
// section on screen is answered by the four behind it too.
if (!active || event.key !== 'Escape' || !detailOpen) return
event.preventDefault()
event.stopPropagation()
detailOpen = false
}
/** Left and Right step between the two pages, which is `PagedContent` answering the
* arrows once it is given this. Forward only goes somewhere once a tool has been
* opened: the detail page has nothing to show before that. */
function navigate(key: string) {
if (key === 'list') detailOpen = false
else if (selected) detailOpen = true
}
</script>
<svelte:window onkeydown={onKeydown} />
<SearchItems {filter} items={tools} bind:filteredItems={nameHits} f={(t) => t.name} />
<SearchItems {filter} items={tools} bind:filteredItems={descriptionHits} f={(t) => t.description} />
<!-- The list and one tool are levels of one panel, the same shape the Skills and MCP
panels use. Warmed once this panel is on screen so the schema table and its
highlighter are built before the click rather than inside the transition. -->
<PagedContent
warm={active}
class="grow min-h-0"
current={detailOpen ? 'detail' : 'list'}
onNavigate={active ? navigate : undefined}
pages={[
{ key: 'list', content: listPage },
{ key: 'detail', content: detailPage }
]}
/>
{#snippet listPage()}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Arrow keys and Enter are caught here so they work whether the search field or a row
holds focus. -->
<div
class="grow min-h-0 overflow-y-auto pr-2"
onkeydown={highlight.onKeydown}
onpointermove={highlight.pointerMoved}
>
<Section
label="Tools"
description="What the assistant can call in this session: the built-in tools, plus whatever the connected MCP servers expose."
>
<!-- Sticks to the top of the scrolling panel so a 70-row list stays searchable. -->
<div class="sticky top-0 z-10 bg-surface pb-2">
<TextInput
bind:value={filter}
size="sm"
inputProps={{ placeholder: 'Search tools', id: SEARCH_INPUT_ID }}
/>
</div>
{#if rows.length === 0}
<div class="py-2 text-xs text-hint">
{tools.length === 0 ? 'This chat carries no tools.' : 'No tool matches this search.'}
</div>
{:else}
<!-- Borderless rows on their own hover, the shape the resource-type picker uses:
a card and dividers around 70 rows read as heavier than the list is. -->
<div class="flex flex-col gap-0.5">
{#each rows as row, index (row.tool.name)}
{#snippet title()}
<span class="truncate font-mono leading-5">
{#if row.name}{@html row.name}{:else}{row.tool.name}{/if}
</span>
{/snippet}
{#snippet subtitle()}
{#if row.description}{@html row.description}{:else}{row.tool.description}{/if}
{/snippet}
<ListRow
id={rowDomId(index)}
{title}
subtitle={row.tool.description ? subtitle : undefined}
highlighted={index === highlight.index}
onMouseEnter={() => highlight.hovered(index)}
onClick={() => open(row.tool)}
/>
{/each}
</div>
{/if}
</Section>
</div>
{/snippet}
{#snippet detailPage()}
<div class="grow min-h-0 overflow-y-auto pr-2">
<!-- Sticky so the way back is always one click away, however far down the arguments go. -->
<div class="flex sticky top-0 z-10 bg-surface pb-1">
<Button
variant="subtle"
unifiedSize="xs"
startIcon={{ icon: ArrowLeft }}
btnClasses="text-secondary"
onClick={() => (detailOpen = false)}
>
Tools
</Button>
</div>
<!-- `headerClass` keeps a long tool name on one line. -->
<Section
label={selected?.name ?? ''}
wrapperClass="mt-1"
headerClass="min-w-0 truncate pr-2 font-mono"
class="flex flex-col gap-4"
>
{#if selected?.description}
<!-- In full, unlike the row, which truncates to one line: most of these run to
several sentences, and this page is where the rest of one lives. -->
<div class="text-xs text-secondary whitespace-pre-line">{selected.description}</div>
{/if}
<SchemaViewer schema={selected?.parameters} />
</Section>
</div>
{/snippet}
@@ -61,6 +61,7 @@
)} )}
onclick={onToggle} onclick={onToggle}
disabled={!toggleable} disabled={!toggleable}
aria-expanded={toggleable ? expanded : undefined}
> >
{#if shimmer} {#if shimmer}
<span class="shimmer inline-flex items-center min-w-0"> <span class="shimmer inline-flex items-center min-w-0">
@@ -1,347 +0,0 @@
<script lang="ts">
import { Button, Drawer } from '$lib/components/common'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import McpConnect from '$lib/components/mcp/McpConnect.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { isMcpEnabled, setMcpEnabled } from '$lib/components/mcp/enabledServers'
import { loadProviderIcon } from '$lib/components/mcp/providerIcon'
import {
cachedProviderKey,
forgetProviderKey,
rememberProviderKey
} from '$lib/components/mcp/iconCache'
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import type { Component } from 'svelte'
import { ResourceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { List, Loader2, Plug, Plus, Trash2 } from 'lucide-svelte'
import type { Item } from '$lib/utils'
import { untrack } from 'svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { clearMcpToolsCache } from './global/mcpTools'
const aiChatManager = getAiChatManager()
// A session chat operates on its own (possibly forked) workspace without
// switching `workspaceStore`, and that is the workspace the chat reads the
// enabled set under. Key everything here the same way or a toggle lands under
// a key nothing reads.
//
// `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 = $workspaceStore
return aiChatManager.operatingWorkspace ?? active!
})
let drawer: Drawer | undefined = $state(undefined)
// Connecting is what the drawer is for, so the card is always up; remounting it
// after a connection is what clears the fields for the next one.
let connectSeq = $state(0)
let servers = $state<
{
path: string
description?: string
editedAt?: string
enabled: boolean
icon?: Component<any>
}[]
>([])
let loading = $state(false)
let loadError = $state<string | undefined>(undefined)
let pendingDisconnect = $state<string | undefined>(undefined)
// Rows describe one workspace. A switch while the drawer is open must not leave
// A's rows on screen while the actions below target B: same path, different
// server, and disconnect would delete the wrong one. Dropping them (and the
// confirmation standing over one of them) is all this does: this component
// mounts with the chat toolbar, so loading here would list resources for every
// user who never opens the menu. The two entry points load what they need.
let loadSeq = 0
$effect(() => {
const target = ws
untrack(() => {
loadSeq++
servers = []
pendingDisconnect = undefined
// A drawer already on screen is neither entry point, and would sit there
// reporting that the new workspace has no connections.
if (drawer?.isOpen()) void loadServers(target)
})
})
async function loadServers(target = ws) {
if (!target) return
const seq = ++loadSeq
loading = true
loadError = undefined
try {
const resources = await ResourceService.listResource({
workspace: target,
resourceType: 'mcp',
perPage: 100
})
if (seq !== loadSeq) return
servers = resources.map((r) => ({
path: r.path,
description: r.description,
editedAt: r.edited_at,
enabled: isMcpEnabled(target, r.path)
}))
void loadIcons(target, seq)
} catch (e) {
if (seq !== loadSeq) return
// Without this the drawer would render the empty state, which reads as
// "you have no connections" rather than "we could not load them".
loadError = e.body ?? e.message
} finally {
if (seq === loadSeq) loading = false
}
}
export async function open() {
drawer?.openDrawer()
await loadServers()
}
// A menu is a shortcut, not a directory: past this many the list stops being
// scannable, so the rest are reached through the drawer rather than dropped.
const MAX_MENU_SERVERS = 8
/** Rows for the chat's "+" menu: one per connected server, checked when it is
* on, then the way to add another. Loaded on open so the checks are current. */
export async function menuItems(closeMenu?: () => void): Promise<Item[]> {
// The menu opens on what is already known and refreshes behind it: waiting on
// a round trip would stall the whole `+` menu, attachments included.
if (servers.length === 0) {
await loadServers()
} else {
void loadServers()
}
// Enabled first: those are the ones a quick visit is most likely about.
const ordered = [...servers].sort(
(a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)
)
const shown = ordered.slice(0, MAX_MENU_SERVERS)
return [
...shown.map(({ path }) => ({
displayName: path,
// Getters, not snapshots: the menu stays open across a click, and it has
// to read through the live list rather than the row captured here, since
// a reload replaces every row object and a getter bound to the old one
// would go on reporting the state it was built with.
get icon() {
// Plug where the provider is unknown, so one nameless server does not
// pull its label out of line with the rest.
return row(path)?.icon ?? Plug
},
// Provider icons take css lengths and ignore lucide's `size`, so without
// this one of them renders at its 24px default among 14px menu icons.
get iconProps() {
return row(path)?.icon ? { width: '14px', height: '14px' } : undefined
},
get toggle() {
return row(path)?.enabled ?? false
},
action: () => toggle(path, !row(path)?.enabled)
})),
...(ordered.length > shown.length
? [
{
displayName: `Show all ${ordered.length}`,
icon: List,
action: () => {
closeMenu?.()
void open()
}
}
]
: []),
{
displayName: 'Connect a server',
icon: Plus,
separatorTop: servers.length > 0,
action: () => {
closeMenu?.()
void open()
}
}
]
}
function row(path: string) {
return servers.find((s) => s.path === path)
}
async function toggle(path: string, enabled: boolean) {
// Local preference only: nothing to re-read from the API, and the cached
// tool lists stay valid because the servers are unchanged.
setMcpEnabled(ws, path, enabled)
const server = servers.find((s) => s.path === path)
if (server) server.enabled = enabled
await aiChatManager.refreshMcpServers()
}
// Deleting a resource also deletes every variable its value references, and an
// mcp resource's token is usually the credential of the resource it was created
// from (the github one). Drop the reference before deleting so disconnecting
// here can never destroy a credential something else still uses; the variable
// is left for the user to remove from the Variables page.
async function disconnect(path: string) {
// Pinned for the whole sequence: a switch midway would strip and delete the
// resource that happens to share this path in the workspace switched to.
const target = ws
try {
const resource = await ResourceService.getResource({
workspace: target,
path
})
const { token: _token, ...withoutToken } = (resource.value ?? {}) as Record<string, unknown>
await ResourceService.updateResource({
workspace: target,
path,
requestBody: { value: withoutToken }
})
await ResourceService.deleteResource({ workspace: target, path })
// A later resource at this path is a different server; it must be turned
// on deliberately rather than inherit this one's enablement.
setMcpEnabled(target, path, false)
forgetProviderKey(target, path)
sendUserToast(`Disconnected ${path}. Its token variable was kept.`)
await refresh()
} catch (e) {
sendUserToast(`Failed to disconnect ${path}: ${e.body ?? e.message}`, true)
} finally {
pendingDisconnect = undefined
}
}
// A row whose provider is already cached paints from the cache; the rest cost
// one read each, and a long list stops asking rather than firing a request
// storm at a screen nobody is reading that far down.
const MAX_ICON_LOOKUPS = 20
async function loadIcons(target: string, seq: number) {
let lookups = 0
await Promise.all(
servers.map(async (server) => {
let key = cachedProviderKey(target, server.path, server.editedAt)
if (key === undefined) {
if (lookups >= MAX_ICON_LOOKUPS) return
lookups++
try {
const resource = await ResourceService.getResource({
workspace: target,
path: server.path
})
key = rememberProviderKey(
target,
server.path,
(resource.value as { url?: unknown } | undefined)?.url,
server.editedAt
)
} catch {
return
}
}
const icon = await loadProviderIcon(key)
if (seq !== loadSeq) return
server.icon = icon
})
)
}
async function refresh() {
// A path can be reconnected to a different server, so the cached tool list
// (and the readOnlyHint the confirmation gate reads) must not survive.
clearMcpToolsCache()
await loadServers()
// Re-register the chat's MCP tools so a connection made here is usable in
// the next message without a reload.
await aiChatManager.refreshMcpServers()
}
</script>
<Drawer bind:this={drawer} size="700px">
<DrawerContent
title="MCP connections"
on:close={() => drawer?.closeDrawer()}
tooltip="Connect an external MCP server to this chat. The chat calls its tools with your own credentials, so it can only reach what you can."
>
<div class="flex flex-col gap-4">
{#key connectSeq}
<McpConnect
workspace={ws}
onConnected={async (connectedWs, path) => {
// Connecting one is the act of choosing it, and it is keyed on where
// it was created rather than on what is on screen now: a switch
// during the popup would otherwise enable the path in a workspace
// that has no such connection.
if (!setMcpEnabled(connectedWs, path, true)) {
sendUserToast(`Connected ${path}, but could not turn it on. Toggle it here.`, true)
}
connectSeq++
await refresh()
}}
/>
{/key}
{#if loading}
<div class="flex justify-center p-4"><Loader2 class="animate-spin" /></div>
{:else if loadError}
<div class="text-xs text-red-600 dark:text-red-400">
Failed to load MCP connections: {loadError}
</div>
{:else if servers.length === 0}
<div class="text-xs text-secondary">No MCP server connected yet.</div>
{:else}
<div class="flex flex-col divide-y border rounded-md bg-surface-tertiary">
{#each servers as server (server.path)}
<div class="flex items-center gap-3 px-4 py-3">
{#if server.icon}
{@const Icon = server.icon}
<Icon width="16px" height="16px" class="shrink-0" />
{:else}
<Plug size={16} class="shrink-0 text-tertiary" />
{/if}
<div class="min-w-0 grow">
<div class="text-xs font-semibold text-emphasis truncate">{server.path}</div>
{#if server.description}
<div class="text-xs text-secondary truncate">{server.description}</div>
{/if}
</div>
<Toggle
size="xs"
checked={server.enabled}
on:change={async (e) => await toggle(server.path, e.detail)}
/>
<Button
unifiedSize="2xs"
variant="subtle"
startIcon={{ icon: Trash2 }}
iconOnly
title="Disconnect"
onClick={() => (pendingDisconnect = server.path)}
/>
</div>
{/each}
</div>
{/if}
</div>
<ConfirmationModal
open={pendingDisconnect !== undefined}
title="Disconnect MCP server"
confirmationText="Disconnect"
onConfirmed={() => {
if (pendingDisconnect) void disconnect(pendingDisconnect)
}}
onCanceled={() => (pendingDisconnect = undefined)}
>
<span class="text-xs text-primary">
This deletes the resource at <span class="font-semibold">{pendingDisconnect}</span>, so the chat
and any flow pointing at it lose the server. Its token variable is kept.
</span>
</ConfirmationModal>
</DrawerContent>
</Drawer>
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { attachmentStatusLabel, countReadyAttachments, summarizeTools } from './agentContext'
import type { Tool } from './shared'
const tool = (name: string, description?: string, parameters?: Record<string, any>) =>
({ def: { type: 'function', function: { name, description, parameters } } }) as unknown as Tool<{}>
describe('summarizeTools', () => {
// The modal re-derives this on every turn, so an unsorted list would reshuffle
// under the reader as tools come and go.
it('sorts by name and tolerates a tool with no description', () => {
expect(summarizeTools([tool('run_script', 'Run it.'), tool('deploy')])).toEqual([
{ name: 'deploy', description: '', parameters: { required: [] } },
{ name: 'run_script', description: 'Run it.', parameters: { required: [] } }
])
})
// `SchemaViewer` renders no argument table at all without `required`, and a tool
// whose arguments are all optional legitimately ships without it.
it('defaults required without dropping the declared schema', () => {
const [summary] = summarizeTools([
tool('open_page', 'Open it.', { type: 'object', properties: { page: { type: 'string' } } })
])
expect(summary.parameters).toEqual({
required: [],
type: 'object',
properties: { page: { type: 'string' } }
})
})
})
describe('countReadyAttachments', () => {
// A folder's own status is an aggregate, and `readyFiles()` filters out the
// placeholder row an empty or all-binary folder keeps — so counting on the folder
// would claim files the assistant cannot open, under a heading about what it can.
it('counts a folder by its readable children, not its own status', () => {
const folders = [
{ files: [{ status: 'indexing' as const }, { status: 'ready' as const }] },
{ files: [] },
{ files: [{ status: 'locked' as const }] }
]
expect(countReadyAttachments(folders, [])).toBe(1)
})
it('counts loose files on their own status', () => {
const files = [
{ status: 'ready' as const },
{ status: 'error' as const },
{ status: 'ready' as const }
]
expect(countReadyAttachments([], files)).toBe(2)
})
})
describe('attachmentStatusLabel', () => {
// Every unreadable status has to say why: the file tools operate on `readyFiles()`,
// so a row that reads like the usable ones is the one place the panel could claim
// something the assistant cannot open.
it('labels every status the file tools cannot read, and only those', () => {
expect(attachmentStatusLabel('ready')).toBeUndefined()
for (const status of ['locked', 'unavailable', 'indexing', 'error'] as const) {
expect(attachmentStatusLabel(status)).toBeTruthy()
}
})
})
@@ -0,0 +1,58 @@
import type { Tool } from './shared'
import type { AttachedFileStatus } from './files/attachedFiles.svelte'
/** One tool as the settings modal lists it the model-facing name, description and
* argument schema, which is exactly what the tool definition sends. */
export type ToolSummary = {
name: string
description: string
/** The JSON Schema of the tool's arguments. `required` is defaulted because
* `SchemaViewer` reads it to mark the rows and renders nothing without it, and a
* tool whose arguments are all optional legitimately omits it. */
parameters: Record<string, any>
}
/** Tool definitions as the modal lists them: name-sorted, so a list of dozens is
* scannable and stays put as the set changes between turns. */
export function summarizeTools(tools: readonly Tool<any>[]): ToolSummary[] {
return tools
.map((t) => ({
name: t.def.function.name,
description: t.def.function.description ?? '',
parameters: { required: [], ...(t.def.function.parameters ?? {}) }
}))
.sort((a, b) => a.name.localeCompare(b.name))
}
/** Why an attachment is not reachable, or undefined when it is. The file tools operate
* on `readyFiles()`, so every other status is attached-but-unreadable and has to say so
* a row that looks like the readable ones is the one place this could claim something
* the assistant cannot actually open. */
export function attachmentStatusLabel(status: AttachedFileStatus): string | undefined {
switch (status) {
case 'ready':
return undefined
case 'locked':
return 'needs access'
case 'unavailable':
return 'unavailable'
case 'indexing':
return 'indexing…'
case 'error':
return 'failed'
}
}
/** How many attachments the assistant can actually read, mirroring `readyFiles()`.
*
* A folder is counted on its children, never on its own status: that status is an
* aggregate, so one indexing child would hide the readable rest, while an empty or
* all-binary folder keeps a `ready` placeholder that `readyFiles()` filters out and
* reads as usable while exposing nothing. */
export function countReadyAttachments(
folders: readonly { files: readonly { status: AttachedFileStatus }[] }[],
files: readonly { status: AttachedFileStatus }[]
): number {
const isReady = (f: { status: AttachedFileStatus }) => f.status === 'ready'
return folders.filter((d) => d.files.some(isReady)).length + files.filter(isReady).length
}
@@ -0,0 +1,144 @@
import { BookOpen, List, Plus } from 'lucide-svelte'
import { get } from 'svelte/store'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import type { Item } from '$lib/utils'
import type { AIChatManager } from '../AIChatManager.svelte'
import { isSkillEnabled, setSkillEnabled } from './enabledSkills'
import { ambiguousSkillNames, listSkillResources, type SkillResource } from './skillResources'
type Row = SkillResource & { enabled: boolean }
// A menu is a shortcut, not a directory: past this many the list stops being
// scannable, so the rest are reached through the settings modal rather than dropped.
const MAX_MENU_SKILLS = 8
/**
* The chat "+" menu's Skills submenu: one row per skill, checked when it is on,
* then the way to manage them. Everything a skill is beyond turning it on and off
* lives in the assistant settings modal, which `onManage` opens.
*/
export class SkillsMenu {
#manager: AIChatManager
#onManage: () => void
#seq = 0
/** Rows for the workspace named by `#rowsWorkspace`, and meaningless for any other. */
#rows = $state<Row[]>([])
#rowsWorkspace: string | undefined = undefined
constructor(manager: AIChatManager, onManage: () => void) {
this.#manager = manager
this.#onManage = onManage
}
// A session chat operates on its own (possibly forked) workspace without
// switching `workspaceStore`, and that is the workspace the chat reads the
// enabled set under. Key everything here the same way or a toggle lands under
// a key nothing reads. Read per call rather than derived: the menu is built
// on open, so there is no stale snapshot to keep current between opens.
get #ws(): string | undefined {
return this.#manager.operatingWorkspace ?? get(workspaceStore) ?? undefined
}
async #load(ws: string) {
const seq = ++this.#seq
try {
// The truncation flag is for the settings modal, which is where a partial
// read is explained; the menu shows what it got.
const { skills: found } = await listSkillResources(ws, get(userStore) ?? undefined)
if (seq !== this.#seq) return
this.#rows = found.map((s) => ({ ...s, enabled: isSkillEnabled(ws, s.path) }))
this.#rowsWorkspace = ws
} catch {
// The menu's other entries still work; a skills submenu that failed to load
// is better empty than blocking the whole "+" menu behind an error.
if (seq !== this.#seq) return
this.#rows = []
this.#rowsWorkspace = ws
}
}
#row(path: string) {
return this.#rows.find((s) => s.path === path)
}
async #toggle(ws: string, path: string, enabled: boolean) {
// A session whose fork is still staged has no workspace of its own yet, so `ws`
// is the PARENT: the selection would be stored under it and quietly stop
// applying the moment the first send commits the fork.
const pendingForkOf = this.#manager.sessionContextResolver?.()?.pendingForkOf
if (pendingForkOf !== undefined) {
sendUserToast(
`This session has not created its workspace yet, so the selection would be stored under "${pendingForkOf}". Send a message first.`,
true
)
return
}
if (!setSkillEnabled(ws, path, enabled)) {
sendUserToast('Could not save the selection for this account.', true)
return
}
const row = this.#row(path)
if (row) row.enabled = enabled
// Whether people select skills at all. Never the skill itself: a path is
// workspace-authored text.
logFeatureUsage('ai_session', 'skill_toggle', { key: enabled ? 'on' : 'off', workspace: ws })
// The prompt lists exactly the enabled skills, so it has to be rebuilt
// before the next message rather than on the next mode change.
await this.#manager.refreshGlobalSkills(ws)
}
/** Loaded on open so the checks are current. */
async items(closeMenu?: () => void): Promise<Item[]> {
const ws = this.#ws
if (!ws) return []
// The menu opens on what is already known and refreshes behind it: awaited
// inline it would stall the whole "+" menu, attachments included. Rows for
// another workspace are not "already known" — same path, different skill.
if (this.#rowsWorkspace !== ws) {
this.#rows = []
await this.#load(ws)
} else {
void this.#load(ws)
}
const ambiguous = ambiguousSkillNames(this.#rows)
// Enabled first: those are the ones a quick visit is most likely about.
const ordered = [...this.#rows].sort(
(a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)
)
const shown = ordered.slice(0, MAX_MENU_SKILLS)
const manage = () => {
closeMenu?.()
this.#onManage()
}
// Bound out here because the getters below sit on plain object literals,
// where `this` is the item rather than this menu.
const row = (path: string) => this.#row(path)
return [
...shown.map(({ path, name }) => ({
// Ambiguous names are shown by path — two rows reading `deploy` would
// leave the choice between them to chance.
displayName: ambiguous.has(name) ? path : name,
icon: BookOpen,
// Getters, not snapshots: the menu stays open across a click, and it has
// to read through the live list rather than the row captured here, since
// a reload replaces every row object and a getter bound to the old one
// would go on reporting the state it was built with.
get toggle() {
return row(path)?.enabled ?? false
},
action: () => this.#toggle(ws, path, !row(path)?.enabled)
})),
...(ordered.length > shown.length
? [{ displayName: `Show all ${ordered.length}`, icon: List, action: manage }]
: []),
{
displayName: this.#rows.length > 0 ? 'Manage skills' : 'Add a skill',
icon: Plus,
separatorTop: this.#rows.length > 0,
action: manage
}
]
}
}
@@ -27,9 +27,12 @@
/** Required: a caller that forgot it would create the connection in whichever /** Required: a caller that forgot it would create the connection in whichever
* workspace the ui happens to be showing, not the one it operates on. */ * workspace the ui happens to be showing, not the one it operates on. */
workspace: string workspace: string
/** Off where the surface around it already draws a card — a popover panel —
* so the two do not stack a border and a background on each other. */
bordered?: boolean
} }
let { onConnected, onCancel, workspace }: Props = $props() let { onConnected, onCancel, workspace, bordered = true }: Props = $props()
let ws = $derived(workspace) let ws = $derived(workspace)
// Any URL is connectable; a suggestion is a shortcut that also pins how the // Any URL is connectable; a suggestion is a shortcut that also pins how the
@@ -130,13 +133,6 @@
let canSignIn = $derived( let canSignIn = $derived(
oauthAppReady || (canDiscover && !!$enterpriseLicense && discoveryFoundOAuth !== false) oauthAppReady || (canDiscover && !!$enterpriseLicense && discoveryFoundOAuth !== false)
) )
// The action button names the credential, not the outcome, so the path field
// says what clicking it will leave behind.
let pathNote = $derived(
canSignIn && !showToken
? 'Signing in saves the connection at this path, as an'
: 'The connection is saved at this path, as an'
)
// Why the token field is the only way in, said where the token is asked for. // Why the token field is the only way in, said where the token is asked for.
let tokenNote = $derived( let tokenNote = $derived(
needsOauthApp && entry needsOauthApp && entry
@@ -338,7 +334,11 @@
} }
</script> </script>
<div class="border rounded p-4 bg-surface-tertiary flex flex-col gap-4"> <div
class={bordered
? 'border rounded p-4 bg-surface-tertiary flex flex-col gap-4'
: 'flex flex-col gap-4'}
>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-sm font-semibold text-emphasis">Connect an MCP server</span> <span class="text-sm font-semibold text-emphasis">Connect an MCP server</span>
{#if onCancel} {#if onCancel}
@@ -442,8 +442,12 @@
{/if} {/if}
<Label label="Save MCP connection to"> <Label label="Save MCP connection to">
<!-- The path decides who gets the connection, and its token with it: the backend
reads both off the path (`u/<name>` is that user's, `f/<folder>` is everyone
with read on the folder), so this is the one place to say so. -->
<span class="text-xs text-secondary"> <span class="text-xs text-secondary">
{pathNote} Under <span class="font-mono">u/{$userStore?.username ?? 'you'}</span> it is yours alone; in
a folder, everyone in it can use the connection and its token. Saved as an
<a <a
href="{base}/resources?workspace={ws}" href="{base}/resources?workspace={ws}"
target="_blank" target="_blank"
@@ -0,0 +1,197 @@
import { List, Plug, Plus } from 'lucide-svelte'
import type { Component } from 'svelte'
import { get } from 'svelte/store'
import { ResourceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import type { Item } from '$lib/utils'
import type { AIChatManager } from '../copilot/chat/AIChatManager.svelte'
import { isMcpEnabled, setMcpEnabled } from './enabledServers'
import { cachedProviderKey, rememberProviderKey } from './iconCache'
import { loadProviderIcon } from './providerIcon'
type Row = {
path: string
editedAt?: string
enabled: boolean
icon?: Component<any>
}
// A menu is a shortcut, not a directory: past this many the list stops being
// scannable, so the rest are reached through the settings modal rather than dropped.
const MAX_MENU_SERVERS = 8
// A row whose provider is already cached paints from the cache; the rest cost one
// read each, and a long list stops asking rather than firing a request storm at a
// menu nobody is reading that far down.
const MAX_ICON_LOOKUPS = 20
/**
* The chat "+" menu's MCP submenu: one row per connected server, checked when it
* is on, then the way to manage them. Connecting and deleting live in the
* assistant settings modal, which `onManage` opens.
*/
export class McpMenu {
#manager: AIChatManager
#onManage: () => void
#seq = 0
/** Rows for the workspace named by `#rowsWorkspace`, and meaningless for any other. */
#rows = $state<Row[]>([])
#rowsWorkspace: string | undefined = undefined
constructor(manager: AIChatManager, onManage: () => void) {
this.#manager = manager
this.#onManage = onManage
}
// A session chat operates on its own (possibly forked) workspace without
// switching `workspaceStore`, and that is the workspace the chat reads the
// enabled set under. Key everything here the same way or a toggle lands under
// a key nothing reads. Read per call rather than derived: the menu is built
// on open, so there is no stale snapshot to keep current between opens.
get #ws(): string | undefined {
return this.#manager.operatingWorkspace ?? get(workspaceStore) ?? undefined
}
async #load(ws: string) {
const seq = ++this.#seq
try {
const resources = await ResourceService.listResource({
workspace: ws,
resourceType: 'mcp',
perPage: 100
})
if (seq !== this.#seq) return
this.#rows = resources.map((r) => ({
path: r.path,
editedAt: r.edited_at,
enabled: isMcpEnabled(ws, r.path)
}))
this.#rowsWorkspace = ws
void this.#loadIcons(ws, seq)
} catch {
// The menu's other entries still work; an MCP submenu that failed to load
// is better empty than blocking the whole "+" menu behind an error.
if (seq !== this.#seq) return
this.#rows = []
this.#rowsWorkspace = ws
}
}
async #loadIcons(ws: string, seq: number) {
let lookups = 0
await Promise.all(
this.#rows.map(async (server) => {
let key = cachedProviderKey(ws, server.path, server.editedAt)
if (key === undefined) {
if (lookups >= MAX_ICON_LOOKUPS) return
lookups++
try {
const resource = await ResourceService.getResource({
workspace: ws,
path: server.path
})
key = rememberProviderKey(
ws,
server.path,
(resource.value as { url?: unknown } | undefined)?.url,
server.editedAt
)
} catch {
return
}
}
const icon = await loadProviderIcon(key)
if (seq !== this.#seq) return
server.icon = icon
})
)
}
#row(path: string) {
return this.#rows.find((s) => s.path === path)
}
async #toggle(ws: string, path: string, enabled: boolean) {
// A session whose fork is still staged has no workspace of its own yet, so `ws`
// is the PARENT: the selection would be stored under it and quietly stop
// applying the moment the first send commits the fork.
const pendingForkOf = this.#manager.sessionContextResolver?.()?.pendingForkOf
if (pendingForkOf !== undefined) {
sendUserToast(
`This session has not created its workspace yet, so the selection would be stored under "${pendingForkOf}". Send a message first.`,
true
)
return
}
// Local preference only: nothing to re-read from the API, and the cached
// tool lists stay valid because the servers are unchanged. Checked, like the
// skills submenu: a refused write leaves the chat carrying a different set than
// the check mark shows.
if (!setMcpEnabled(ws, path, enabled)) {
sendUserToast('Could not save the selection for this account.', true)
return
}
const row = this.#row(path)
if (row) row.enabled = enabled
await this.#manager.refreshMcpServers(ws)
}
/** Loaded on open so the checks are current. */
async items(closeMenu?: () => void): Promise<Item[]> {
const ws = this.#ws
if (!ws) return []
// The menu opens on what is already known and refreshes behind it: awaited
// inline it would stall the whole "+" menu, attachments included. Rows for
// another workspace are not "already known" — same path, different server.
if (this.#rowsWorkspace !== ws) {
this.#rows = []
await this.#load(ws)
} else {
void this.#load(ws)
}
// Enabled first: those are the ones a quick visit is most likely about.
const ordered = [...this.#rows].sort(
(a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)
)
const shown = ordered.slice(0, MAX_MENU_SERVERS)
const manage = () => {
closeMenu?.()
this.#onManage()
}
// Bound out here because the getters below sit on plain object literals,
// where `this` is the item rather than this menu.
const row = (path: string) => this.#row(path)
return [
...shown.map(({ path }) => ({
displayName: path,
// Getters, not snapshots: the menu stays open across a click, and it has
// to read through the live list rather than the row captured here, since
// a reload replaces every row object and a getter bound to the old one
// would go on reporting the state it was built with.
get icon() {
// Plug where the provider is unknown, so one nameless server does not
// pull its label out of line with the rest.
return row(path)?.icon ?? Plug
},
// Provider icons take css lengths and ignore lucide's `size`, so without
// this one of them renders at its 24px default among 14px menu icons.
get iconProps() {
return row(path)?.icon ? { width: '14px', height: '14px' } : undefined
},
get toggle() {
return row(path)?.enabled ?? false
},
action: () => this.#toggle(ws, path, !row(path)?.enabled)
})),
...(ordered.length > shown.length
? [{ displayName: `Show all ${ordered.length}`, icon: List, action: manage }]
: []),
{
displayName: 'Connect a server',
icon: Plus,
separatorTop: this.#rows.length > 0,
action: manage
}
]
}
}
@@ -12,7 +12,7 @@ function mcpTokenDescription(resourcePath: string): string {
/** /**
* Store a connection's token at `path`. * Store a connection's token at `path`.
* *
* Disconnecting an MCP server deletes the resource but deliberately keeps its * Deleting an MCP connection deletes the resource but deliberately keeps its
* token variable, because `delete_resource` cascade-deletes every variable the * token variable, because `delete_resource` cascade-deletes every variable the
* value references and that credential may still belong to another resource. So * value references and that credential may still belong to another resource. So
* reconnecting the same server lands on an existing path, which is the only case * reconnecting the same server lands on an existing path, which is the only case
@@ -519,6 +519,12 @@ function createRuntime(session: Session): SessionRuntime {
// Key the store before any configureGlobalMode runs, so a new session's first create shows at once. // Key the store before any configureGlobalMode runs, so a new session's first create shows at once.
void manager.artifacts.setSession(session.id) void manager.artifacts.setSession(session.id)
// Assigning `manager.mode` above only records the mode; this builds the tool set
// and system prompt from it. Keep it here, after the resolvers and the artifact
// store it reads, and not on `changeMode`: a runtime exists per session the
// picker lists, so changeMode's network refreshes would fire once per listing.
manager.configureGlobalMode()
// Pipeline target state lives on the runtime (not the PipelineEditorView // Pipeline target state lives on the runtime (not the PipelineEditorView
// component) so the in-session drafts survive hide/show of the editor pane — // component) so the in-session drafts survive hide/show of the editor pane —
// the pane unmounts on hide, and a component-local store would be discarded. // the pane unmounts on hide, and a component-local store would be discarded.
@@ -0,0 +1,107 @@
<script lang="ts">
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
import { GripVertical, Plus } from 'lucide-svelte'
import { randomUUID } from '$lib/utils/uuid'
import type { S3ResourceSettingsItem } from '$lib/workspace_settings'
import Alert from '../common/alert/Alert.svelte'
import Button from '../common/button/Button.svelte'
import ClearableInput from '../common/clearableInput/ClearableInput.svelte'
import CloseButton from '../common/CloseButton.svelte'
import MultiSelect from '../select/MultiSelect.svelte'
type Rule = NonNullable<S3ResourceSettingsItem['advancedPermissions']>[number]
let { rules = $bindable() }: { rules: Rule[] | undefined } = $props()
// svelte-dnd-action keys its items by `id`. Wrapping the rules rather than adding
// an `id` to them keeps the key out of what gets persisted to the backend.
let items = $state((rules ?? []).map((rule) => ({ id: randomUUID(), rule })))
$effect(() => {
rules = items.map((item) => item.rule)
})
// Evaluation stops at the first rule whose pattern matches, so a rule matching every
// path makes everything below it dead — most often the `**/*` deny-all the default
// ruleset ends with.
const CATCH_ALL_PATTERNS = ['**/*', '**', '*']
let catchAllIdx = $derived.by(() => {
const idx = items.findIndex((item) => CATCH_ALL_PATTERNS.includes(item.rule.pattern.trim()))
return idx === -1 || idx === items.length - 1 ? undefined : idx
})
let shadowWarning = $derived.by(() => {
if (catchAllIdx === undefined) return undefined
const shadowed =
catchAllIdx === items.length - 2
? `Rule ${items.length} is`
: `Rules ${catchAllIdx + 2} to ${items.length} are`
return `${shadowed} never evaluated: rule ${catchAllIdx + 1} (${items[catchAllIdx].rule.pattern.trim()}) already matches every path`
})
const flipDurationMs = 200
</script>
<Alert title="Rules are evaluated in order">
The first rule whose pattern matches the path decides what is allowed — drag rules to reorder
them. A path matched by no rule is denied.
<br /><br />
Standard Unix-style glob syntax is supported. The following will be interpolated:
<ul class="list-disc pl-6">
<li><code>{'{username}'}</code> : Nickname of the user doing the request</li>
<li><code>{'{group}'}</code> : Any group that the user belongs to</li>
<li><code>{'{folder_read}'}</code> : Any folder that the user has read access to</li>
<li><code>{'{folder_write}'}</code> : Any folder that the user has write access to</li>
</ul>
<br />
Note that changes may take up to 1 minute to propagate due to cache invalidation
</Alert>
<div class="flex-1 overflow-y-auto">
<section
class="flex flex-col gap-3"
use:dragHandleZone={{ items, flipDurationMs, dropTargetStyle: {} }}
onconsider={(e) => (items = e.detail.items)}
onfinalize={(e) => (items = e.detail.items)}
>
{#each items as item, idx (item.id)}
{@const shadowed = catchAllIdx !== undefined && idx > catchAllIdx}
<!-- The transparent border is carried by every row so flagging one doesn't shift the columns. -->
<div
class="flex gap-2 items-center pl-2 border-l-2 {shadowed
? 'border-red-500'
: 'border-transparent'}"
>
<div
class="shrink-0 flex items-center gap-1 cursor-move {shadowed
? 'text-red-500'
: 'text-secondary'}"
use:dragHandle
aria-label="Reorder rule {idx + 1}"
>
<GripVertical size={16} />
<span class="text-2xs whitespace-nowrap tabular-nums">Rule {idx + 1}</span>
</div>
<ClearableInput bind:value={item.rule.pattern} placeholder="Pattern" />
<MultiSelect
items={[{ value: 'read' }, { value: 'write' }, { value: 'delete' }, { value: 'list' }]}
bind:value={item.rule.allow}
class="w-[20rem]"
placeholder="Deny all access"
hideMainClearBtn
/>
<CloseButton onClick={() => (items = items.filter((_, i) => i !== idx))} />
</div>
{/each}
</section>
</div>
{#if shadowWarning}
<Alert type="error" size="xs" title={shadowWarning} />
{/if}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Plus }}
on:click={() => (items = [...items, { id: randomUUID(), rule: { pattern: '', allow: [] } }])}
>
Add permission rule
</Button>
@@ -19,9 +19,8 @@
import S3FilePicker from '../S3FilePicker.svelte' import S3FilePicker from '../S3FilePicker.svelte'
import Portal from '../Portal.svelte' import Portal from '../Portal.svelte'
import Popover from '../meltComponents/Popover.svelte' import Popover from '../meltComponents/Popover.svelte'
import ClearableInput from '../common/clearableInput/ClearableInput.svelte'
import MultiSelect from '../select/MultiSelect.svelte'
import CloseButton from '../common/CloseButton.svelte' import CloseButton from '../common/CloseButton.svelte'
import S3PermissionRulesEditor from './S3PermissionRulesEditor.svelte'
import TextInput from '../text_input/TextInput.svelte' import TextInput from '../text_input/TextInput.svelte'
import Select from '../select/Select.svelte' import Select from '../select/Select.svelte'
import DataTable from '../table/DataTable.svelte' import DataTable from '../table/DataTable.svelte'
@@ -529,7 +528,7 @@
disabled={!storage.advancedPermissions && !$enterpriseLicense} disabled={!storage.advancedPermissions && !$enterpriseLicense}
/> />
{#if storage.advancedPermissions} {#if storage.advancedPermissions}
{@render advancedPermissionsEditor(storage.advancedPermissions)} <S3PermissionRulesEditor bind:rules={storage.advancedPermissions} />
{/if} {/if}
{#if !storage.advancedPermissions} {#if !storage.advancedPermissions}
{#if storage.resourceType == 's3'} {#if storage.resourceType == 's3'}
@@ -585,37 +584,3 @@
{/if} {/if}
{/if} {/if}
</Modal2> </Modal2>
{#snippet advancedPermissionsEditor(rules: S3ResourceSettingsItem['advancedPermissions'])}
<Alert title="Standard Unix-style glob syntax is supported">
The following will be interpolated :
<ul class="list-disc pl-6">
<li><code>{'{username}'}</code> : Nickname of the user doing the request</li>
<li><code>{'{group}'}</code> : Any group that the user belongs to</li>
<li><code>{'{folder_read}'}</code> : Any folder that the user has read access to</li>
<li><code>{'{folder_write}'}</code> : Any folder that the user has write access to</li>
</ul>
<br />
Note that changes may take up to 1 minute to propagate due to cache invalidation
</Alert>
<div class="flex-1 overflow-y-auto gap-3 flex flex-col">
{#each rules ?? [] as item, idx}
<div class="flex gap-2">
<ClearableInput bind:value={item.pattern} placeholder="Pattern" />
<MultiSelect
items={[{ value: 'read' }, { value: 'write' }, { value: 'delete' }, { value: 'list' }]}
bind:value={item.allow}
class="w-[20rem]"
placeholder="Deny all access"
hideMainClearBtn
/>
<CloseButton onClick={() => rules?.splice(idx, 1)} />
</div>
{/each}
</div>
<Button size="xs" variant="default" on:click={() => rules?.push({ pattern: '', allow: [] })}>
<Plus size={14} />
Add permission rule
</Button>
{/snippet}