mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
Merge remote-tracking branch 'origin/main' into license-app-only-implicit-promotion
This commit is contained in:
@@ -1 +1 @@
|
||||
cea88af9477b4e33832fbad5c5aefbc62762067f
|
||||
00bf1dde9c396382a2db30ff7a5f1703c2611304
|
||||
@@ -29,6 +29,10 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"scopes": ["https://www.googleapis.com/auth/spreadsheets"],
|
||||
"scope_options": [
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/spreadsheets.readonly"
|
||||
],
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -38,6 +42,11 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"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": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -47,6 +56,13 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"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": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -56,6 +72,12 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"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": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -65,6 +87,12 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"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": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -74,6 +102,10 @@
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"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": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
@@ -88,6 +120,15 @@
|
||||
"https://www.googleapis.com/auth/admin.directory.user.security",
|
||||
"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": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
|
||||
@@ -88,6 +88,8 @@ pub struct OAuthConfig {
|
||||
#[serde(default = "empty_string")]
|
||||
pub token_url: 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>>,
|
||||
/// Default scopes for the client-credentials (2-legged) flow. These differ
|
||||
/// from the authorization-code `scopes` for most providers (member/consent
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
} from '$lib/gen'
|
||||
import { emptyString, truncateRev, urlize } from '$lib/utils'
|
||||
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 { Button, RadioCard, Skeleton } from './common'
|
||||
import { ListRow, RadioCard, Skeleton } from './common'
|
||||
import { useListHighlight } from './common/listRow/listHighlight.svelte'
|
||||
import ApiConnectForm from './ApiConnectForm.svelte'
|
||||
import SearchItems from './SearchItems.svelte'
|
||||
import WhitelistIp from './WhitelistIp.svelte'
|
||||
@@ -42,7 +43,6 @@
|
||||
import SyncResourceTypes from './SyncResourceTypes.svelte'
|
||||
import Label from './Label.svelte'
|
||||
import ResourcePathHint from './ResourcePathHint.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
step?: number
|
||||
@@ -1027,15 +1027,8 @@
|
||||
// Both lists start undefined and render skeletons; "nothing found" only means something
|
||||
// once they have landed.
|
||||
let listsLoaded = $derived(rankedConnectsManual !== undefined && rankedConnects !== undefined)
|
||||
let highlightedIndex = $state(-1)
|
||||
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 otherRowOffset = $derived(customKeys.length + (rankedConnects?.length ?? 0))
|
||||
|
||||
@@ -1054,53 +1047,23 @@
|
||||
return best
|
||||
}
|
||||
|
||||
// Filtering reshuffles the rows under the highlight: point it at the best match so Enter
|
||||
// takes the top hit, and drop it entirely once the filter is cleared.
|
||||
$effect(() => {
|
||||
navItems
|
||||
filter
|
||||
untrack(() => (highlightedIndex = searching ? bestMatchIndex() : -1))
|
||||
const highlight = useListHighlight({
|
||||
count: () => navItems.length,
|
||||
rowId: rowDomId,
|
||||
// Sections are rendered in a fixed order, so the best match is not necessarily the
|
||||
// first row; Enter should still take the top hit.
|
||||
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) {
|
||||
if (step !== 1) return
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
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)
|
||||
}
|
||||
highlight.onKeydown(e)
|
||||
}
|
||||
|
||||
let editScopes = $state(false)
|
||||
@@ -1132,7 +1095,7 @@
|
||||
<div
|
||||
class="flex flex-col h-full min-h-0"
|
||||
onkeydown={onListKeydown}
|
||||
onpointermove={() => (pointerOwnsHighlight = true)}
|
||||
onpointermove={highlight.pointerMoved}
|
||||
>
|
||||
<div class="shrink-0 pb-4">
|
||||
<div class="relative w-full">
|
||||
@@ -1146,28 +1109,6 @@
|
||||
</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)}
|
||||
<h2 class="mb-3 text-2xs font-normal uppercase text-secondary">
|
||||
{title}{#if searching}<span class="ml-2 text-hint">{count}</span>{/if}
|
||||
@@ -1175,26 +1116,29 @@
|
||||
{/snippet}
|
||||
|
||||
{#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)}
|
||||
aiId={`app-connect-inner-${oauth ? 'oauth-' : ''}${key}`}
|
||||
aiDescription={`Connect to ${key}${oauth ? ' with the instance OAuth client' : ''}`}
|
||||
unifiedSize="md"
|
||||
variant="subtle"
|
||||
btnClasses={twMerge(
|
||||
'justify-start px-3 h-auto py-3 scroll-my-2',
|
||||
// The pointer moves the same highlight the arrow keys move, so the variant's
|
||||
// own hover is off: two lit rows at once would be ambiguous.
|
||||
'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>
|
||||
{icon}
|
||||
{title}
|
||||
subtitle={resourceTypeDescriptions[key] ? subtitle : undefined}
|
||||
highlighted={index === highlight.index}
|
||||
onMouseEnter={() => highlight.hovered(index)}
|
||||
onClick={() => (oauth ? connectOauth(key) : selectFromOthers(key))}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto">
|
||||
@@ -1213,7 +1157,7 @@
|
||||
{#if customKeys.length > 0}
|
||||
<section>
|
||||
{@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}
|
||||
{@render resourceButton(key, i, false)}
|
||||
{/each}
|
||||
@@ -1227,7 +1171,7 @@
|
||||
'Instance-configured OAuth APIs',
|
||||
rankedConnects?.length ?? 0
|
||||
)}
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if rankedConnects}
|
||||
{#each rankedConnects as { key }, i}
|
||||
{@render resourceButton(key, oauthRowOffset + i, true)}
|
||||
@@ -1259,7 +1203,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if rankedConnectsManual}
|
||||
{#each otherKeys as key, i}
|
||||
{@render resourceButton(key, otherRowOffset + i, false)}
|
||||
@@ -1532,7 +1476,7 @@
|
||||
>
|
||||
|
||||
{#if editScopes}
|
||||
<OauthScopes bind:scopes />
|
||||
<OauthScopes bind:scopes options={registryEntry()?.scope_options} />
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each scopes as scope}
|
||||
|
||||
@@ -1,51 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { Button } from './common'
|
||||
import TextInput from './text_input/TextInput.svelte'
|
||||
import Checkbox from './common/checkbox/Checkbox.svelte'
|
||||
import { Minus, Plus } from 'lucide-svelte'
|
||||
|
||||
interface Props {
|
||||
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(() => {
|
||||
if (!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>
|
||||
|
||||
{#if scopes && Array.isArray(scopes)}
|
||||
{#each scopes as v, i}
|
||||
<div class="flex flex-row max-w-md mb-2">
|
||||
<input type="text" bind:value={scopes[i]} />
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
btnClasses="mx-6"
|
||||
on:click={() => {
|
||||
scopes = scopes?.filter((el) => el != v)
|
||||
}}
|
||||
startIcon={{ icon: Minus }}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if options.length > 0}
|
||||
<div class="flex flex-col gap-1 mb-2">
|
||||
{#each options as option (option)}
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<Checkbox
|
||||
checked={ticked.includes(option)}
|
||||
onChange={() => toggle(option, !ticked.includes(option))}
|
||||
/>
|
||||
<span class="font-mono break-all">{option}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="text-xs text-secondary">Custom scopes</span>
|
||||
{/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">
|
||||
<Button
|
||||
variant="default"
|
||||
hover="yo"
|
||||
size="xs"
|
||||
unifiedSize="sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
on:click={() => {
|
||||
scopes = (scopes ?? []).concat('')
|
||||
onclick={() => {
|
||||
write(ticked, [...custom, ''])
|
||||
}}
|
||||
>
|
||||
Add item
|
||||
</Button>
|
||||
<span class="ml-2 text-xs text-primary font-normal">
|
||||
({(scopes ?? []).length} item{(scopes ?? []).length > 1 ? 's' : ''})
|
||||
</span>
|
||||
{#if custom.length > 0}
|
||||
<span class="ml-2 text-xs text-primary font-normal">
|
||||
({custom.length} item{custom.length > 1 ? 's' : ''})
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -320,7 +320,9 @@
|
||||
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
|
||||
try {
|
||||
for (const ws of dirty) {
|
||||
@@ -368,8 +370,10 @@
|
||||
dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource`
|
||||
)
|
||||
dispatch('refresh', current?.path ?? path)
|
||||
return true
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true)
|
||||
return false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
transition:slide={animate || collapsable ? { duration: 200 } : { duration: 0 }}
|
||||
>
|
||||
{#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}
|
||||
<div class={twMerge('flex flex-col gap-6 grow min-h-0', headless ? '' : 'mt-4')}>
|
||||
<div class={twMerge('grow min-h-0', clazz)}>
|
||||
|
||||
@@ -63,6 +63,10 @@
|
||||
/** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. */
|
||||
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
|
||||
// Monaco fires onDidChangeModelContent synchronously from within `setValue`, so without
|
||||
// this an authoritative overwrite reads as a user edit on the `input` event.
|
||||
@@ -112,7 +116,8 @@
|
||||
minHeight = 1000,
|
||||
renderLineHighlight = 'none',
|
||||
suggestion,
|
||||
leadingChangeSync = false
|
||||
leadingChangeSync = false,
|
||||
lineNumbersMinChars = 3
|
||||
}: {
|
||||
lang: string
|
||||
code?: string
|
||||
@@ -149,6 +154,9 @@
|
||||
* `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). */
|
||||
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()
|
||||
|
||||
let yPadding = MONACO_Y_PADDING
|
||||
@@ -312,10 +320,12 @@
|
||||
if (model.getLanguageId() !== lang) {
|
||||
const currentCode = model.getValue()
|
||||
const uri = `file:///${hash}.${langToExt(lang)}`
|
||||
const oldModel = model
|
||||
const newModel = meditor.createModel(currentCode, lang, mUri.parse(uri))
|
||||
editor?.setModel(newModel)
|
||||
oldModel.dispose()
|
||||
// The old model goes first: `langToExt` maps anything it does not know to
|
||||
// `unknown`, so the new uri is usually the one this model already holds,
|
||||
// and creating over an occupied uri throws ("model already exists").
|
||||
editor?.setModel(null)
|
||||
model.dispose()
|
||||
editor?.setModel(meditor.createModel(currentCode, lang, mUri.parse(uri)))
|
||||
}
|
||||
|
||||
// Update editor options for suggestions, validation decorations, and line numbers
|
||||
@@ -334,8 +344,8 @@
|
||||
snippetsPreventQuickSuggestions: disableSuggestions
|
||||
},
|
||||
lineNumbers: hideLineNumbers ? 'off' : 'on',
|
||||
lineDecorationsWidth: hideLineNumbers ? 0 : 6,
|
||||
lineNumbersMinChars: hideLineNumbers ? 0 : 2,
|
||||
lineDecorationsWidth: hideLineNumbers ? 0 : LINE_DECORATIONS_WIDTH,
|
||||
lineNumbersMinChars: hideLineNumbers ? 0 : lineNumbersMinChars,
|
||||
// Hide validation squiggles and decorations
|
||||
renderValidationDecorations: disableLinting ? 'off' : 'on',
|
||||
// Hide the validation margin indicators
|
||||
@@ -397,8 +407,11 @@
|
||||
...(yPadding !== undefined ? { padding: { bottom: yPadding, top: yPadding } } : {}),
|
||||
readOnly,
|
||||
renderLineHighlight,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbersMinChars: 2,
|
||||
// Same conditional as `updateModelAndOptions`: created correct rather than
|
||||
// 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,
|
||||
quickSuggestions: disableSuggestions
|
||||
? { 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() {
|
||||
files = undefined
|
||||
dispatchChange()
|
||||
|
||||
@@ -20,6 +20,7 @@ export { default as TabFade } from './tabs/TabFade.svelte'
|
||||
export { default as Tabs } from './tabs/Tabs.svelte'
|
||||
export { default as Breadcrumb } from './breadcrumb/Breadcrumb.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 Section } from '../Section.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
|
||||
* here and close the underlying modal. */
|
||||
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
|
||||
* than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */
|
||||
formStyling?: boolean
|
||||
@@ -46,6 +50,7 @@
|
||||
fixedHeight = 'md',
|
||||
contentClasses = '',
|
||||
closeOnOutsideClick = true,
|
||||
closeOnEscape = true,
|
||||
formStyling = false,
|
||||
headerLeft,
|
||||
headerRight,
|
||||
@@ -80,7 +85,7 @@
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isOpen) return
|
||||
if (!isOpen || !closeOnEscape) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
* 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 —
|
||||
* 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
|
||||
/**
|
||||
@@ -80,6 +83,9 @@
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
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
|
||||
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
|
||||
if (step === 0) return
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ComponentType } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { arrowTabNav } from '$lib/attachments/arrowTabNav'
|
||||
import EEOnly from '$lib/components/EEOnly.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
|
||||
@@ -32,7 +33,10 @@
|
||||
let { groups, selectedId, onNavigate, class: className = '' }: Props = $props()
|
||||
</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)}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if group.title}
|
||||
|
||||
@@ -35,8 +35,9 @@
|
||||
import ChatQuickActions from './ChatQuickActions.svelte'
|
||||
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
|
||||
import AIChatModelSettings from './AIChatModelSettings.svelte'
|
||||
import McpConnections from './McpConnections.svelte'
|
||||
import SkillsPicker from './SkillsPicker.svelte'
|
||||
import AssistantSettingsModal from './AssistantSettingsModal.svelte'
|
||||
import { SkillsMenu } from './skills/skillsMenu.svelte'
|
||||
import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte'
|
||||
import ChatMode from './ChatMode.svelte'
|
||||
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
@@ -207,8 +208,11 @@
|
||||
} = $props()
|
||||
|
||||
let aiChatInput: AIChatInput | undefined = $state()
|
||||
let mcpConnections: McpConnections | undefined = $state()
|
||||
let skillsPicker: SkillsPicker | undefined = $state()
|
||||
let assistantSettings: AssistantSettingsModal | 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 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 inGlobal = aiChatManager.mode === AIMode.GLOBAL
|
||||
const [skillItems, mcpItems] = await Promise.all([
|
||||
inGlobal ? skillsPicker?.menuItems(closeMenu) : undefined,
|
||||
inGlobal ? mcpConnections?.menuItems(closeMenu) : undefined
|
||||
inGlobal ? skillsMenu.items(closeMenu) : undefined,
|
||||
inGlobal ? mcpMenu.items(closeMenu) : undefined
|
||||
])
|
||||
return [
|
||||
{
|
||||
@@ -1143,10 +1147,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
|
||||
<DatatableCreationPolicy />
|
||||
{/if}
|
||||
<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}
|
||||
<SkillsPicker bind:this={skillsPicker} />
|
||||
<McpConnections bind:this={mcpConnections} />
|
||||
<AssistantSettingsModal bind:this={assistantSettings} />
|
||||
{/if}
|
||||
|
||||
{#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
|
||||
// so changeMode, refreshGlobalSkills, and setPipelineHelpers stay consistent —
|
||||
// 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), {
|
||||
previewTools: this.isSessionChat,
|
||||
user: this.globalIdentity,
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
type ReasoningProviderModel
|
||||
} 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 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"
|
||||
>
|
||||
<!-- 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="px-3 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary">Model</div>
|
||||
@@ -409,21 +418,24 @@
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
|
||||
<AIPromptsModal
|
||||
bind:open={modalOpen}
|
||||
bind:customPrompts
|
||||
scope={modalScope}
|
||||
modes={[activeMode]}
|
||||
readOnly={modalReadOnly}
|
||||
{readOnlyReason}
|
||||
onSave={modalReadOnly ? undefined : save}
|
||||
onReset={reset}
|
||||
{hasChanges}
|
||||
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
|
||||
target="body"
|
||||
fixedHeight="sm"
|
||||
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
|
||||
/>
|
||||
<!-- Only where the entries that open it are rendered. -->
|
||||
{#if promptSettings}
|
||||
<AIPromptsModal
|
||||
bind:open={modalOpen}
|
||||
bind:customPrompts
|
||||
scope={modalScope}
|
||||
modes={[activeMode]}
|
||||
readOnly={modalReadOnly}
|
||||
{readOnlyReason}
|
||||
onSave={modalReadOnly ? undefined : save}
|
||||
onReset={reset}
|
||||
{hasChanges}
|
||||
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
|
||||
target="body"
|
||||
fixedHeight="sm"
|
||||
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* 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>
|
||||
+477
-382
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
disabled={!toggleable}
|
||||
aria-expanded={toggleable ? expanded : undefined}
|
||||
>
|
||||
{#if shimmer}
|
||||
<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
|
||||
* workspace the ui happens to be showing, not the one it operates on. */
|
||||
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)
|
||||
// Any URL is connectable; a suggestion is a shortcut that also pins how the
|
||||
@@ -130,13 +133,6 @@
|
||||
let canSignIn = $derived(
|
||||
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.
|
||||
let tokenNote = $derived(
|
||||
needsOauthApp && entry
|
||||
@@ -338,7 +334,11 @@
|
||||
}
|
||||
</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">
|
||||
<span class="text-sm font-semibold text-emphasis">Connect an MCP server</span>
|
||||
{#if onCancel}
|
||||
@@ -442,8 +442,12 @@
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
{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
|
||||
href="{base}/resources?workspace={ws}"
|
||||
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`.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -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 Portal from '../Portal.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 S3PermissionRulesEditor from './S3PermissionRulesEditor.svelte'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import DataTable from '../table/DataTable.svelte'
|
||||
@@ -529,7 +528,7 @@
|
||||
disabled={!storage.advancedPermissions && !$enterpriseLicense}
|
||||
/>
|
||||
{#if storage.advancedPermissions}
|
||||
{@render advancedPermissionsEditor(storage.advancedPermissions)}
|
||||
<S3PermissionRulesEditor bind:rules={storage.advancedPermissions} />
|
||||
{/if}
|
||||
{#if !storage.advancedPermissions}
|
||||
{#if storage.resourceType == 's3'}
|
||||
@@ -585,37 +584,3 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user