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 @@
{#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()}
+
+{: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}