From 0d6bce4a12226adb83fc6050064eb2e80372e36e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 3 Sep 2026 23:23:59 +0000 Subject: [PATCH 1/7] keep the SSO group reconciler alive in oauth2-less builds (#10969) * chore: stop denying reads of secret files in claude settings Any Read() deny rule makes Claude Code resolve the file operands of every Bash command that reads files. A path it cannot resolve, such as one that follows a cd into a directory the analyzer does not track, escalates to a permission prompt even under bypassPermissions. A plain recursive grep in the repo root escalates too, because it could reach .env. Drop the read rules and widen the write rules to cover the same files, so secrets still cannot be written through Edit, Write, or a shell redirect. Reads of those files are no longer blocked. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RNCupPk2yewQT1JMNjkV8M * fix: keep the sso group reconciler alive in oauth2-less builds Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W24T1FjQXQ87AoeC3UxWWC * chore: update ee-repo-ref to d6297e6844dc2aab4745fce328e32ccab508969f This commit updates the EE repository reference after PR #777 was merged in windmill-ee-private. Previous ee-repo-ref: eec88486fb2df0ba15998ef285f52fc67af90b1e New ee-repo-ref: d6297e6844dc2aab4745fce328e32ccab508969f Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 354e88bef1..9e7a10edf0 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -3b89bfc11314a326a191101cfe3ef65f6f7f82a8 +d6297e6844dc2aab4745fce328e32ccab508969f From fda7b3f086619e3716e5894c07be127104174f1d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 4 Sep 2026 08:53:02 +0000 Subject: [PATCH 2/7] feat(ai-sessions): replace the context panel with an assistant settings modal (#10919) * feat(ai-chat): make reusable skills ai_skill resources you select per workspace * chore: pin the ee ref to the skill telemetry counters * fix: address review findings on skill authoring, import and migration * fix: enforce skill selection in read_skill and stop imports clobbering resources * feat: carry format_extension from the hub into synced resource types * fix: let an edit set or clear a resource type's format_extension * fix: regenerate the sqlx cache and close the review round findings * fix: close the round-2 findings on folder ACLs, cached sync and truncation * refactor: make the skills migration non-destructive and use design-system inputs * feat(ai-sessions): add a context panel listing what the chat can use Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NmdCVM1ZvTcpatv78jN8Ed * fix: track the prompt rebuild signal and trim the review round's nits Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NmdCVM1ZvTcpatv78jN8Ed * fix: keep the panel from perturbing an in-flight turn Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NmdCVM1ZvTcpatv78jN8Ed * fix: count a folder by its readable children Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NmdCVM1ZvTcpatv78jN8Ed * feat(ai-sessions): replace the context panel with an assistant settings modal Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * feat(ai-sessions): page-based MCP editing and fuzzy tool search Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * feat(ai-sessions): tool detail page and a shared list row Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * feat(ai-sessions): add a files & folders section to the assistant settings Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * chore: point the ee ref at the merged ee branch Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: keep hidden sections from answering keys and swallowing a failed save Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: restore the staged-fork write guard and narrow the round-2 findings Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: restore the workspace-race guards and extend them to MCP Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: keep an in-flight settings read from overwriting typed instructions Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * docs: describe the tool row as the one line it renders Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: keep the prompt entries on the home composer, which has no settings modal Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: create the editor with the gutter its caller asked for Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * test: restore the attachment status label guard dropped in the merge Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: surface a refused mcp selection write instead of painting the switch Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: refuse instruction writes to a staged fork's parent, and read the target's role Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: pin the instructions role and field to the target workspace Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ld1m9NAGLdPPrNBiu5PSQK * fix: retry a deferred instructions reload, and use Button for the row label Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGnKFvaX61wiMU1CzQp6XG * fix: leave the arrows to a control that answered them, and say when a role read failed Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGnKFvaX61wiMU1CzQp6XG * chore: update ee-repo-ref to a2776856c50e80c9dbcf6e689a66ce86567c03fa This commit updates the EE repository reference after PR #765 was merged in windmill-ee-private. Previous ee-repo-ref: dd7466e749753568a23c91ba5e165020769206b8 New ee-repo-ref: a2776856c50e80c9dbcf6e689a66ce86567c03fa Automated by sync-ee-ref workflow. * fix: withhold the page navigator from a parked section Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WGnKFvaX61wiMU1CzQp6XG --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Guilhem Lemouel Co-authored-by: windmill-internal-app[bot] --- .../src/lib/components/AppConnectInner.svelte | 136 +-- .../src/lib/components/ResourceEditor.svelte | 6 +- frontend/src/lib/components/Section.svelte | 2 +- .../src/lib/components/SimpleEditor.svelte | 31 +- .../common/fileInput/FileInput.svelte | 7 + frontend/src/lib/components/common/index.ts | 1 + .../components/common/listRow/ListRow.svelte | 142 +++ .../common/listRow/listHighlight.svelte.ts | 84 ++ .../lib/components/common/modal/Modal2.svelte | 7 +- .../common/modal/PagedContent.svelte | 6 + .../common/sidebar/SidebarNavigation.svelte | 6 +- .../copilot/chat/AIChatDisplay.svelte | 24 +- .../copilot/chat/AIChatManager.svelte.ts | 5 +- .../copilot/chat/AIChatModelSettings.svelte | 44 +- .../copilot/chat/AssistantFilesSection.svelte | 158 ++++ .../chat/AssistantInstructionsSection.svelte | 354 ++++++++ .../copilot/chat/AssistantMcpSection.svelte | 632 +++++++++++++ .../chat/AssistantSettingsModal.svelte | 238 +++++ ...r.svelte => AssistantSkillsSection.svelte} | 859 ++++++++++-------- .../copilot/chat/AssistantToolsSection.svelte | 212 +++++ .../copilot/chat/ChatCollapsibleCard.svelte | 1 + .../copilot/chat/McpConnections.svelte | 347 ------- .../copilot/chat/agentContext.test.ts | 65 ++ .../components/copilot/chat/agentContext.ts | 58 ++ .../copilot/chat/skills/skillsMenu.svelte.ts | 144 +++ .../src/lib/components/mcp/McpConnect.svelte | 24 +- .../src/lib/components/mcp/mcpMenu.svelte.ts | 197 ++++ .../src/lib/components/mcp/secretVariable.ts | 2 +- .../sessions/sessionRuntime.svelte.ts | 6 + 29 files changed, 2923 insertions(+), 875 deletions(-) create mode 100644 frontend/src/lib/components/common/listRow/ListRow.svelte create mode 100644 frontend/src/lib/components/common/listRow/listHighlight.svelte.ts create mode 100644 frontend/src/lib/components/copilot/chat/AssistantFilesSection.svelte create mode 100644 frontend/src/lib/components/copilot/chat/AssistantInstructionsSection.svelte create mode 100644 frontend/src/lib/components/copilot/chat/AssistantMcpSection.svelte create mode 100644 frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte rename frontend/src/lib/components/copilot/chat/{SkillsPicker.svelte => AssistantSkillsSection.svelte} (50%) create mode 100644 frontend/src/lib/components/copilot/chat/AssistantToolsSection.svelte delete mode 100644 frontend/src/lib/components/copilot/chat/McpConnections.svelte create mode 100644 frontend/src/lib/components/copilot/chat/agentContext.test.ts create mode 100644 frontend/src/lib/components/copilot/chat/agentContext.ts create mode 100644 frontend/src/lib/components/copilot/chat/skills/skillsMenu.svelte.ts create mode 100644 frontend/src/lib/components/mcp/mcpMenu.svelte.ts diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index a9a0837d1f..3d73cea72f 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -21,9 +21,10 @@ } from '$lib/gen' import { emptyString, truncateRev, urlize } from '$lib/utils' import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry' - import { createEventDispatcher, onDestroy, tick, untrack } from 'svelte' + import { createEventDispatcher, onDestroy, tick } from 'svelte' import Path from './Path.svelte' - import { Button, RadioCard, Skeleton } from './common' + import { ListRow, RadioCard, Skeleton } from './common' + import { useListHighlight } from './common/listRow/listHighlight.svelte' import ApiConnectForm from './ApiConnectForm.svelte' import SearchItems from './SearchItems.svelte' import WhitelistIp from './WhitelistIp.svelte' @@ -42,7 +43,6 @@ import SyncResourceTypes from './SyncResourceTypes.svelte' import Label from './Label.svelte' import ResourcePathHint from './ResourcePathHint.svelte' - import { twMerge } from 'tailwind-merge' interface Props { step?: number @@ -1027,15 +1027,8 @@ // Both lists start undefined and render skeletons; "nothing found" only means something // once they have landed. let listsLoaded = $derived(rankedConnectsManual !== undefined && rankedConnects !== undefined) - let highlightedIndex = $state(-1) const rowDomId = (index: number) => `resource-type-row-${index}` - // Set at hover time rather than up front, so only the descriptions the row actually cut - // off carry a tooltip. - function titleIfTruncated(e: MouseEvent & { currentTarget: HTMLElement }) { - const el = e.currentTarget - el.title = el.scrollWidth > el.clientWidth ? (el.textContent?.trim() ?? '') : '' - } const oauthRowOffset = $derived(customKeys.length) const otherRowOffset = $derived(customKeys.length + (rankedConnects?.length ?? 0)) @@ -1054,53 +1047,23 @@ return best } - // Filtering reshuffles the rows under the highlight: point it at the best match so Enter - // takes the top hit, and drop it entirely once the filter is cleared. - $effect(() => { - navItems - filter - untrack(() => (highlightedIndex = searching ? bestMatchIndex() : -1)) + const highlight = useListHighlight({ + count: () => navItems.length, + rowId: rowDomId, + // Sections are rendered in a fixed order, so the best match is not necessarily the + // first row; Enter should still take the top hit. + restingIndex: () => (searching ? bestMatchIndex() : -1), + onActivate: (index) => { + const item = navItems[index] + if (!item) return + item.oauth ? connectOauth(item.key) : selectFromOthers(item.key) + }, + activateEnterFrom: [SEARCH_INPUT_ID] }) - // Scrolling rows under a resting pointer makes the browser fire `mouseenter` on each one, - // which would drag the highlight back under the cursor as the arrow keys move it. Only a - // real pointer move hands the highlight back to the mouse. - let pointerOwnsHighlight = $state(true) - - function highlightHovered(index: number) { - if (pointerOwnsHighlight) highlightedIndex = index - } - - function moveHighlight(delta: number) { - const count = navItems.length - if (count === 0) return - pointerOwnsHighlight = false - // Rows are tabbable buttons, so focus can sit on one. Enter then activates whatever is - // focused, which has to stay the highlighted row. - const rowWasFocused = document.activeElement?.id?.startsWith('resource-type-row-') ?? false - highlightedIndex = - highlightedIndex < 0 - ? delta > 0 - ? 0 - : count - 1 - : (highlightedIndex + delta + count) % count - const row = document.getElementById(rowDomId(highlightedIndex)) - row?.scrollIntoView({ block: 'nearest' }) - if (rowWasFocused) row?.focus() - } - function onListKeydown(e: KeyboardEvent) { if (step !== 1) return - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault() - moveHighlight(e.key === 'ArrowDown' ? 1 : -1) - } else if (e.key === 'Enter' && (e.target as HTMLElement)?.id === SEARCH_INPUT_ID) { - // A focused row activates itself on Enter; this covers Enter typed in the search field. - const item = navItems[highlightedIndex] - if (!item) return - e.preventDefault() - item.oauth ? connectOauth(item.key) : selectFromOthers(item.key) - } + highlight.onKeydown(e) } let editScopes = $state(false) @@ -1132,7 +1095,7 @@
(pointerOwnsHighlight = true)} + onpointermove={highlight.pointerMoved} >
@@ -1146,28 +1109,6 @@
- {#snippet resourceRow(key: string)} -
-
- -
-
-
- {resourceTypeDisplayName(key)} - {key} -
- {#if resourceTypeDescriptions[key]} - - {plainDescription(resourceTypeDescriptions[key])} - - {/if} -
-
- {/snippet} - {#snippet sectionHeading(title: string, count: number)}

{title}{#if searching}{count}{/if} @@ -1175,26 +1116,29 @@ {/snippet} {#snippet resourceButton(key: string, index: number, oauth: boolean)} - + {icon} + {title} + subtitle={resourceTypeDescriptions[key] ? subtitle : undefined} + highlighted={index === highlight.index} + onMouseEnter={() => highlight.hovered(index)} + onClick={() => (oauth ? connectOauth(key) : selectFromOthers(key))} + /> {/snippet}
@@ -1213,7 +1157,7 @@ {#if customKeys.length > 0}
{@render sectionHeading('Custom resource types', customKeys.length)} -
+
{#each customKeys as key, i} {@render resourceButton(key, i, false)} {/each} @@ -1227,7 +1171,7 @@ 'Instance-configured OAuth APIs', rankedConnects?.length ?? 0 )} -
+
{#if rankedConnects} {#each rankedConnects as { key }, i} {@render resourceButton(key, oauthRowOffset + i, true)} @@ -1259,7 +1203,7 @@
{/if} -
+
{#if rankedConnectsManual} {#each otherKeys as key, i} {@render resourceButton(key, otherRowOffset + i, false)} diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 367888655f..7b5cbacb5d 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -320,7 +320,9 @@ current.path = npath } - export async function save(): Promise { + /** 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 { const dirty = dirtyWorkspaces try { for (const ws of dirty) { @@ -368,8 +370,10 @@ dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource` ) dispatch('refresh', current?.path ?? path) + return true } catch (err) { sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true) + return false } } diff --git a/frontend/src/lib/components/Section.svelte b/frontend/src/lib/components/Section.svelte index f1425c51b7..badc7c9531 100644 --- a/frontend/src/lib/components/Section.svelte +++ b/frontend/src/lib/components/Section.svelte @@ -102,7 +102,7 @@ transition:slide={animate || collapsable ? { duration: 200 } : { duration: 0 }} > {#if description} -
{@html description}
+
{@html description}
{/if}
diff --git a/frontend/src/lib/components/SimpleEditor.svelte b/frontend/src/lib/components/SimpleEditor.svelte index 65713e19f7..8133494f18 100644 --- a/frontend/src/lib/components/SimpleEditor.svelte +++ b/frontend/src/lib/components/SimpleEditor.svelte @@ -63,6 +63,10 @@ /** Trailing debounce window (ms) on Monaco's onDidChangeModelContent. */ const CHANGE_TIMEOUT = 200 + /** Gap between the line numbers and the first character. Zero puts them flush, + * so a two-digit line reads as one token with the code. */ + const LINE_DECORATIONS_WIDTH = 6 + let changeTimeoutId: number | undefined = undefined // Monaco fires onDidChangeModelContent synchronously from within `setValue`, so without // this an authoritative overwrite reads as a user edit on the `input` event. @@ -112,7 +116,8 @@ minHeight = 1000, renderLineHighlight = 'none', suggestion, - leadingChangeSync = false + leadingChangeSync = false, + lineNumbersMinChars = 3 }: { lang: string code?: string @@ -149,6 +154,9 @@ * `code`; leave it off where each extra sync costs work downstream (an app * code input feeding an autoRefresh runnable re-runs a job per sync). */ leadingChangeSync?: boolean + /** Width of the line-number gutter, in characters. Same name, and same + * default, as `Editor`, so the two render line numbers alike. */ + lineNumbersMinChars?: number } = $props() let yPadding = MONACO_Y_PADDING @@ -312,10 +320,12 @@ if (model.getLanguageId() !== lang) { const currentCode = model.getValue() const uri = `file:///${hash}.${langToExt(lang)}` - const oldModel = model - const newModel = meditor.createModel(currentCode, lang, mUri.parse(uri)) - editor?.setModel(newModel) - oldModel.dispose() + // The old model goes first: `langToExt` maps anything it does not know to + // `unknown`, so the new uri is usually the one this model already holds, + // and creating over an occupied uri throws ("model already exists"). + editor?.setModel(null) + model.dispose() + editor?.setModel(meditor.createModel(currentCode, lang, mUri.parse(uri))) } // Update editor options for suggestions, validation decorations, and line numbers @@ -334,8 +344,8 @@ snippetsPreventQuickSuggestions: disableSuggestions }, lineNumbers: hideLineNumbers ? 'off' : 'on', - lineDecorationsWidth: hideLineNumbers ? 0 : 6, - lineNumbersMinChars: hideLineNumbers ? 0 : 2, + lineDecorationsWidth: hideLineNumbers ? 0 : LINE_DECORATIONS_WIDTH, + lineNumbersMinChars: hideLineNumbers ? 0 : lineNumbersMinChars, // Hide validation squiggles and decorations renderValidationDecorations: disableLinting ? 'off' : 'on', // Hide the validation margin indicators @@ -397,8 +407,11 @@ ...(yPadding !== undefined ? { padding: { bottom: yPadding, top: yPadding } } : {}), readOnly, renderLineHighlight, - lineDecorationsWidth: 0, - lineNumbersMinChars: 2, + // Same conditional as `updateModelAndOptions`: created correct rather than + // created wide and narrowed a tick later, which a caller hiding the gutter + // would see as a flash of indent. + lineDecorationsWidth: hideLineNumbers ? 0 : LINE_DECORATIONS_WIDTH, + lineNumbersMinChars: hideLineNumbers ? 0 : lineNumbersMinChars, fontSize: fontSize, quickSuggestions: disableSuggestions ? { other: false, comments: false, strings: false } diff --git a/frontend/src/lib/components/common/fileInput/FileInput.svelte b/frontend/src/lib/components/common/fileInput/FileInput.svelte index 11f7a0caa9..bde73b8b69 100644 --- a/frontend/src/lib/components/common/fileInput/FileInput.svelte +++ b/frontend/src/lib/components/common/fileInput/FileInput.svelte @@ -201,6 +201,13 @@ } } + /** Open the file chooser without the dropzone being clicked, for a caller whose + * affordance is a button elsewhere. The component is still what reads and filters + * the files, so the two paths cannot drift. */ + export function openPicker() { + input?.click() + } + export function clearFiles() { files = undefined dispatchChange() diff --git a/frontend/src/lib/components/common/index.ts b/frontend/src/lib/components/common/index.ts index 531036c3de..bb0268b39b 100644 --- a/frontend/src/lib/components/common/index.ts +++ b/frontend/src/lib/components/common/index.ts @@ -20,6 +20,7 @@ export { default as TabFade } from './tabs/TabFade.svelte' export { default as Tabs } from './tabs/Tabs.svelte' export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte' export { default as FileInput } from './fileInput/FileInput.svelte' +export { default as ListRow } from './listRow/ListRow.svelte' export { default as RadioCard } from './radioCard/RadioCard.svelte' export { default as Section } from '../Section.svelte' export { default as Url } from './Url.svelte' diff --git a/frontend/src/lib/components/common/listRow/ListRow.svelte b/frontend/src/lib/components/common/listRow/ListRow.svelte new file mode 100644 index 0000000000..dfa56d5af3 --- /dev/null +++ b/frontend/src/lib/components/common/listRow/ListRow.svelte @@ -0,0 +1,142 @@ + + + +{#snippet body()} +
+ {#if icon} +
{@render icon()}
+ {/if} +
+
+ {@render title()} +
+ {#if subtitle} + + + {@render subtitle()} + + {/if} +
+
+{/snippet} + +{#if trailing} + +
+ {#if onClick} + + + {:else} + +
{@render body()}
+ {/if} + {@render trailing()} +
+{:else if !onClick} + + +
+ {@render body()} +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/common/listRow/listHighlight.svelte.ts b/frontend/src/lib/components/common/listRow/listHighlight.svelte.ts new file mode 100644 index 0000000000..4da6f18f32 --- /dev/null +++ b/frontend/src/lib/components/common/listRow/listHighlight.svelte.ts @@ -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) + } + } + } +} diff --git a/frontend/src/lib/components/common/modal/Modal2.svelte b/frontend/src/lib/components/common/modal/Modal2.svelte index f33b1dec04..b9e57740b9 100644 --- a/frontend/src/lib/components/common/modal/Modal2.svelte +++ b/frontend/src/lib/components/common/modal/Modal2.svelte @@ -26,6 +26,10 @@ * and clicks "outside" the child would otherwise propagate * here and close the underlying modal. */ closeOnOutsideClick?: boolean + /** Close on Escape. Default true. Every open modal listens on the + * window, so a stacked pair would both close on one press; set it + * false on the underlying modal while its child is up. */ + closeOnEscape?: boolean /** Wider side padding and a lighter title, for a dialog whose body is a form rather * than a list. Opt-in: every other Modal2 keeps the padding and heading it had. */ formStyling?: boolean @@ -46,6 +50,7 @@ fixedHeight = 'md', contentClasses = '', closeOnOutsideClick = true, + closeOnEscape = true, formStyling = false, headerLeft, headerRight, @@ -80,7 +85,7 @@ } function handleKeyDown(event: KeyboardEvent) { - if (!isOpen) return + if (!isOpen || !closeOnEscape) return if (event.key === 'Escape') { event.preventDefault() event.stopPropagation() diff --git a/frontend/src/lib/components/common/modal/PagedContent.svelte b/frontend/src/lib/components/common/modal/PagedContent.svelte index 430a53e66f..674c147c52 100644 --- a/frontend/src/lib/components/common/modal/PagedContent.svelte +++ b/frontend/src/lib/components/common/modal/PagedContent.svelte @@ -26,6 +26,9 @@ * around this owns it, and a page component that swallowed it would stop the dialog from * closing. Without this prop the pages are still navigable, just not from the keyboard — * the caller owns `current` either way. + * + * A host that stays mounted while hidden must withhold it while hidden: the arrows are + * answered at `window`, so a parked instance would take the key off the visible one. */ onNavigate?: (key: string) => void /** @@ -80,6 +83,9 @@ function onKeydown(event: KeyboardEvent) { if (!onNavigate || !listening() || event.metaKey || event.ctrlKey || event.altKey) return + // A control on the page that already answered the key keeps it: the arrows move focus + // inside a toggle group, a menu, a slider, and those handlers run before this one. + if (event.defaultPrevented) return if (!ownsKeyboard(event.target)) return const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0 if (step === 0) return diff --git a/frontend/src/lib/components/common/sidebar/SidebarNavigation.svelte b/frontend/src/lib/components/common/sidebar/SidebarNavigation.svelte index 260e368e2e..d18df4c655 100644 --- a/frontend/src/lib/components/common/sidebar/SidebarNavigation.svelte +++ b/frontend/src/lib/components/common/sidebar/SidebarNavigation.svelte @@ -2,6 +2,7 @@ import type { ComponentType } from 'svelte' import { twMerge } from 'tailwind-merge' import Button from '$lib/components/common/button/Button.svelte' + import { arrowTabNav } from '$lib/attachments/arrowTabNav' import EEOnly from '$lib/components/EEOnly.svelte' import { enterpriseLicense } from '$lib/stores' @@ -32,7 +33,10 @@ let { groups, selectedId, onNavigate, class: className = '' }: Props = $props() -
+ +
{#each groups as group (group.title)}
{#if group.title} diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index f610dd5ff4..699098c116 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -35,8 +35,9 @@ import ChatQuickActions from './ChatQuickActions.svelte' import ContextUsageIndicator from './ContextUsageIndicator.svelte' import AIChatModelSettings from './AIChatModelSettings.svelte' - import McpConnections from './McpConnections.svelte' - import SkillsPicker from './SkillsPicker.svelte' + import AssistantSettingsModal from './AssistantSettingsModal.svelte' + import { SkillsMenu } from './skills/skillsMenu.svelte' + import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte' import ChatMode from './ChatMode.svelte' import DatatableCreationPolicy from './DatatableCreationPolicy.svelte' import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' @@ -207,8 +208,11 @@ } = $props() let aiChatInput: AIChatInput | undefined = $state() - let mcpConnections: McpConnections | undefined = $state() - let skillsPicker: SkillsPicker | undefined = $state() + let assistantSettings: AssistantSettingsModal | undefined = $state() + // The "+" menu's skill and MCP rows: enough state to check and flip one, with + // everything else about them behind the assistant settings modal. + const skillsMenu = new SkillsMenu(aiChatManager, () => assistantSettings?.open('skills')) + const mcpMenu = new McpMenu(aiChatManager, () => assistantSettings?.open('mcp')) let plusMenuOpen = $state(false) let editingMessageIndex = $state(null) @@ -959,8 +963,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> const closeMenu = () => (plusMenuOpen = false) const inGlobal = aiChatManager.mode === AIMode.GLOBAL const [skillItems, mcpItems] = await Promise.all([ - inGlobal ? skillsPicker?.menuItems(closeMenu) : undefined, - inGlobal ? mcpConnections?.menuItems(closeMenu) : undefined + inGlobal ? skillsMenu.items(closeMenu) : undefined, + inGlobal ? mcpMenu.items(closeMenu) : undefined ]) return [ { @@ -1143,10 +1147,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/if} - + + {#if aiChatManager.mode === AIMode.GLOBAL} - - + {/if} {#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index fb54204fe9..43df13f78c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2123,7 +2123,10 @@ export class AIChatManager { // pipeline surface when a /pipeline editor has registered helpers. Centralized // so changeMode, refreshGlobalSkills, and setPipelineHelpers stay consistent — // each rebuild would otherwise drop the pipeline augmentation the others added. - private configureGlobalMode = () => { + // + // Public because it is purely local, unlike `changeMode(GLOBAL)`, which also + // fires the three network refreshes. + configureGlobalMode = () => { const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), { previewTools: this.isSessionChat, user: this.globalIdentity, diff --git a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte index 123768d3f3..9dd94fa4ea 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatModelSettings.svelte @@ -33,6 +33,13 @@ type ReasoningProviderModel } from '../reasoningRegistry' + let { + /** Whether this dropdown carries the custom-prompt entries. Off where the surface + * has an assistant settings modal — its Instructions section owns them there, and + * two ways in would drift. The home composer has no such modal, so it keeps them. */ + promptSettings = true + }: { promptSettings?: boolean } = $props() + const aiChatManager = getAiChatManager() const AI_SETTINGS_HREF = `${base}/workspace_settings?tab=ai` @@ -335,7 +342,9 @@ class="bg-surface-tertiary dark:border w-64 origin-top-right rounded-lg shadow-lg focus:outline-none py-1 text-xs" > - + {#if promptSettings} + + {/if}
Model
@@ -409,21 +418,24 @@ {/snippet} - + +{#if promptSettings} + +{/if}