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