From afa360b78f61d42389399239e404dbe5b04e8e57 Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Mon, 14 Sep 2026 19:48:47 +0800 Subject: [PATCH 1/4] feat(panel): show scroll-to-bottom control when not at latest Add a floating chevron over the conversation pane so users who scrolled up can jump back to the newest messages, and only auto-follow while already near the bottom. --- extensions/dsh-browser/src/panel/App.tsx | 43 +++++++++++++++- extensions/dsh-browser/src/panel/scroll.ts | 13 +++++ extensions/dsh-browser/src/panel/strings.ts | 3 ++ extensions/dsh-browser/src/panel/styles.css | 51 +++++++++++++++++++ .../dsh-browser/tests/panel-styles.spec.ts | 14 +++++ .../dsh-browser/tests/scroll-bottom.spec.ts | 18 +++++++ 6 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 extensions/dsh-browser/src/panel/scroll.ts create mode 100644 extensions/dsh-browser/tests/scroll-bottom.spec.ts diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index 51c455f7c..1caa4827f 100644 --- a/extensions/dsh-browser/src/panel/App.tsx +++ b/extensions/dsh-browser/src/panel/App.tsx @@ -63,6 +63,7 @@ import { restoreSubmittedDraft, type ComposerDraft, } from './composer.ts' +import { isNearScrollBottom } from './scroll.ts' import { latestSessionTitle, projectedSessionTitle, @@ -672,8 +673,30 @@ export function App(): React.JSX.Element { const seqRef = useRef(0) const sessionRef = useRef(null) const scrollRef = useRef(null) + const atBottomRef = useRef(true) + const [atBottom, setAtBottom] = useState(true) const nextSeq = (): number => { seqRef.current += 1; return seqRef.current } + + function syncScrollBottom(): void { + const element = scrollRef.current + if (element === null) return + const next = isNearScrollBottom(element) + atBottomRef.current = next + setAtBottom(next) + } + + function stickConversationToBottom(): void { + atBottomRef.current = true + setAtBottom(true) + } + + function scrollMessagesToBottom(behavior: ScrollBehavior = 'smooth'): void { + const element = scrollRef.current + if (element === null) return + stickConversationToBottom() + element.scrollTo({ top: element.scrollHeight, behavior }) + } const question = questions[0] ?? null const questionSubmitting = question !== null && hasPendingQuestion(questionSubmissions, question) const sessionSwitchBlocked = sessionChanging || busy || addingImages || stopping @@ -782,6 +805,7 @@ export function App(): React.JSX.Element { followSnapshotsRef.current.clear() pendingHistoriesRef.current.clear() streamRefreshRef.current.clear() + stickConversationToBottom() setStreamRow(null) setRows([]) setDraft((current) => ({ ...current, images: [] })) @@ -840,8 +864,9 @@ export function App(): React.JSX.Element { if (sessionId !== undefined && queuedApproval !== undefined) void focusApprovalSession(queuedApproval) }, [queuedApproval?.id, queuedApproval?.sessionId, sessionChanging, state]) - // Auto-scroll to the newest row. + // Stick to the newest row only while the user is already near the bottom. useEffect(() => { + if (!atBottomRef.current) return scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) }, [rows, streamRow, working]) @@ -1299,6 +1324,7 @@ export function App(): React.JSX.Element { nextQuestions: PendingQuestion[] = [], preserveSelection = false, ): void { + stickConversationToBottom() setRows([]) setStreamRow(null) setDraft(emptyComposerDraft()) @@ -2000,7 +2026,8 @@ export function App(): React.JSX.Element { )} )} -
+
+
{rows.length === 0 && streamRow === null && !working && (
@@ -2036,6 +2063,18 @@ export function App(): React.JSX.Element {
)}
+ {!atBottom && ( + + )} +
{question !== null && ( , + threshold = 48, +): boolean { + return element.scrollHeight - element.scrollTop - element.clientHeight <= threshold +} diff --git a/extensions/dsh-browser/src/panel/strings.ts b/extensions/dsh-browser/src/panel/strings.ts index d255d342a..4689aa3ac 100644 --- a/extensions/dsh-browser/src/panel/strings.ts +++ b/extensions/dsh-browser/src/panel/strings.ts @@ -207,6 +207,7 @@ export interface PanelCopy { selectionAttached: string selectionTruncated: string removeSelection: string + scrollToBottom: string } } @@ -437,6 +438,7 @@ const EN: PanelCopy = { selectionAttached: 'Selected text', selectionTruncated: '(truncated)', removeSelection: 'Remove the selected text', + scrollToBottom: 'Scroll to latest', }, } @@ -667,6 +669,7 @@ const ZH: PanelCopy = { selectionAttached: '选中的网页内容', selectionTruncated: '(已截断)', removeSelection: '移除选中内容', + scrollToBottom: '回到最新消息', }, } diff --git a/extensions/dsh-browser/src/panel/styles.css b/extensions/dsh-browser/src/panel/styles.css index 2f22fc12a..cce073ac5 100644 --- a/extensions/dsh-browser/src/panel/styles.css +++ b/extensions/dsh-browser/src/panel/styles.css @@ -538,6 +538,14 @@ textarea:focus-visible { justify-self: end; } +.messages-pane { + position: relative; + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + .messages { display: flex; min-height: 0; @@ -551,6 +559,49 @@ textarea:focus-visible { scrollbar-gutter: stable; } +.scroll-to-bottom { + position: absolute; + right: 16px; + bottom: 12px; + z-index: 2; + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 999px; + background: var(--surface); + color: var(--muted); + box-shadow: var(--shadow); + cursor: pointer; + transition: border-color 150ms ease, background-color 150ms ease, color 150ms ease, box-shadow 150ms ease, transform 150ms ease; +} + +.scroll-to-bottom svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} + +.scroll-to-bottom:hover { + border-color: #aebcff; + color: var(--ink-strong); + box-shadow: 0 0 0 3px rgba(77, 107, 254, 0.1), var(--shadow); +} + +.scroll-to-bottom:active { + transform: translateY(1px); +} + +.scroll-to-bottom:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 2px; +} + .empty { display: flex; width: 100%; diff --git a/extensions/dsh-browser/tests/panel-styles.spec.ts b/extensions/dsh-browser/tests/panel-styles.spec.ts index f676d9a98..92d943b21 100644 --- a/extensions/dsh-browser/tests/panel-styles.spec.ts +++ b/extensions/dsh-browser/tests/panel-styles.spec.ts @@ -21,4 +21,18 @@ describe('panel layout styles', () => { expect(settingsChildrenRule).toBeDefined() expect(settingsChildrenRule).toMatch(/(?:^|\n)\s*flex-shrink:\s*0;/) }) + + it('floats a scroll-to-bottom control over the conversation pane', () => { + const styles = readFileSync(`${process.cwd()}/src/panel/styles.css`, 'utf8') + const paneRule = styles.match(/\.messages-pane\s*\{([^}]*)\}/)?.[1] + const buttonRule = styles.match(/\.scroll-to-bottom\s*\{([^}]*)\}/)?.[1] + + expect(paneRule).toBeDefined() + expect(paneRule).toMatch(/(?:^|\n)\s*position:\s*relative;/) + expect(paneRule).toMatch(/(?:^|\n)\s*flex:\s*1;/) + expect(buttonRule).toBeDefined() + expect(buttonRule).toMatch(/(?:^|\n)\s*position:\s*absolute;/) + expect(buttonRule).toMatch(/(?:^|\n)\s*right:\s*16px;/) + expect(buttonRule).toMatch(/(?:^|\n)\s*bottom:\s*12px;/) + }) }) diff --git a/extensions/dsh-browser/tests/scroll-bottom.spec.ts b/extensions/dsh-browser/tests/scroll-bottom.spec.ts new file mode 100644 index 000000000..69a3d5db9 --- /dev/null +++ b/extensions/dsh-browser/tests/scroll-bottom.spec.ts @@ -0,0 +1,18 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { isNearScrollBottom } from '../src/panel/scroll.ts' + +describe('isNearScrollBottom', () => { + it('treats a fully scrolled viewport as near the bottom', () => { + expect(isNearScrollBottom({ scrollTop: 200, scrollHeight: 500, clientHeight: 300 })).toBe(true) + }) + + it('allows a small slack threshold above the true bottom', () => { + expect(isNearScrollBottom({ scrollTop: 160, scrollHeight: 500, clientHeight: 300 }, 48)).toBe(true) + expect(isNearScrollBottom({ scrollTop: 140, scrollHeight: 500, clientHeight: 300 }, 48)).toBe(false) + }) + + it('treats short unscrollable content as near the bottom', () => { + expect(isNearScrollBottom({ scrollTop: 0, scrollHeight: 200, clientHeight: 300 })).toBe(true) + }) +}) From 5513239b66acdffd5321176c270dace3c611971a Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Mon, 14 Sep 2026 19:57:40 +0800 Subject: [PATCH 2/4] fix(panel): resync scroll-bottom state on pane resize Observe the conversation scrollport with ResizeObserver so sibling UI that changes layout still keeps the jump-to-latest control accurate. --- extensions/dsh-browser/src/panel/App.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index 1caa4827f..50e13ba51 100644 --- a/extensions/dsh-browser/src/panel/App.tsx +++ b/extensions/dsh-browser/src/panel/App.tsx @@ -683,7 +683,7 @@ export function App(): React.JSX.Element { if (element === null) return const next = isNearScrollBottom(element) atBottomRef.current = next - setAtBottom(next) + setAtBottom((current) => current === next ? current : next) } function stickConversationToBottom(): void { @@ -870,6 +870,18 @@ export function App(): React.JSX.Element { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) }, [rows, streamRow, working]) + // Sibling chrome (session picker, question card, errors, attachments) can + // resize the scrollport without a scroll event; keep bottom state honest. + useEffect(() => { + if (showSettings) return + const element = scrollRef.current + if (element === null || typeof ResizeObserver === 'undefined') return + syncScrollBottom() + const observer = new ResizeObserver(() => { syncScrollBottom() }) + observer.observe(element) + return () => observer.disconnect() + }, [showSettings, question, error, showSessionPicker, draftImages.length, selection]) + function applyImageProjection(sessionId: string, seq: number, value: unknown): void { if (sessionRef.current !== sessionId || !Number.isSafeInteger(seq)) return const previous = imageProjectionRef.current From 8e2d18dde303552a833a1b1e34e81220199a4a76 Mon Sep 17 00:00:00 2001 From: zhaozian <1161954287@qq.com> Date: Wed, 16 Sep 2026 18:38:03 +0800 Subject: [PATCH 3/4] checkpoint before checking out feat/remote-modal-settings-style --- extensions/dsh-browser/src/panel/App.tsx | 241 +++++++++++++++++- extensions/dsh-browser/src/panel/strings.ts | 6 + extensions/dsh-browser/src/panel/styles.css | 160 ++++++++++++ .../dsh-browser/tests/panel-styles.spec.ts | 19 +- .../bridge-browser/src/remote-host-api.ts | 46 ++++ .../bridge-browser/src/session-deferral.ts | 168 ++++++++++++ .../tests/remote-host-api.spec.ts | 32 ++- .../tests/session-deferral.spec.ts | 60 +++++ 8 files changed, 720 insertions(+), 12 deletions(-) diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index 50e13ba51..8e6baadea 100644 --- a/extensions/dsh-browser/src/panel/App.tsx +++ b/extensions/dsh-browser/src/panel/App.tsx @@ -111,6 +111,21 @@ interface RelayProfileDraft { /** Route keys managed by the relay editor; core-owned routes are never touched. */ const RELAY_ROUTE_PREFIX = 'relay-' +/** One selectable entry from `session.models` for the composer chip list. */ +interface SessionModelOption { + provider: string + providerName: string + id: string + name: string +} + +/** Advisory directory snapshot used by the composer model switcher. */ +interface SessionModelDirectory { + current: { provider: string; model: string; reasoningEffort?: string } | null + options: SessionModelOption[] + routable: boolean | null +} + /** * Display names are free-form (CJK included); the route key needs the * ASCII shape the wire and credential refs expect, so CJK-heavy names @@ -641,6 +656,10 @@ export function App(): React.JSX.Element { const [relayLoaded, setRelayLoaded] = useState(false) const [relayNotice, setRelayNotice] = useState(null) const [relayBusy, setRelayBusy] = useState(false) + const [sessionModels, setSessionModels] = useState(null) + const [modelSelecting, setModelSelecting] = useState(false) + const [showModelPicker, setShowModelPicker] = useState(false) + const modelPickerRef = useRef(null) const [sessionTitle, setSessionTitle] = useState(null) const [resumeHint, setResumeHint] = useState<{ ready: boolean; sessionId: string | null }>({ ready: false, sessionId: null }) const [questions, setQuestions] = useState([]) @@ -811,6 +830,9 @@ export function App(): React.JSX.Element { setDraft((current) => ({ ...current, images: [] })) setImageLimits(null) imageProjectionRef.current = { sessionId: null, seq: Number.NEGATIVE_INFINITY, limits: null } + setSessionModels(null) + setModelSelecting(false) + setShowModelPicker(false) setSessionTitle(null) setWorking(false) setStopping(false) @@ -882,6 +904,24 @@ export function App(): React.JSX.Element { return () => observer.disconnect() }, [showSettings, question, error, showSessionPicker, draftImages.length, selection]) + useEffect(() => { + if (!showModelPicker) return + const onPointerDown = (event: PointerEvent): void => { + const root = modelPickerRef.current + if (root !== null && event.target instanceof Node && root.contains(event.target)) return + setShowModelPicker(false) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') setShowModelPicker(false) + } + document.addEventListener('pointerdown', onPointerDown, true) + document.addEventListener('keydown', onKeyDown, true) + return () => { + document.removeEventListener('pointerdown', onPointerDown, true) + document.removeEventListener('keydown', onKeyDown, true) + } + }, [showModelPicker]) + function applyImageProjection(sessionId: string, seq: number, value: unknown): void { if (sessionRef.current !== sessionId || !Number.isSafeInteger(seq)) return const previous = imageProjectionRef.current @@ -1139,6 +1179,124 @@ export function App(): React.JSX.Element { setSessionTitle(null) sessionRuntimeRef.current.seedRunning(created.sessionId, false) applyHistory(created.sessionId, await readHistory(created.sessionId)) + await refreshSessionModels(created.sessionId) + } + + /** Pull the advisory model directory for the active session into the composer menu. */ + async function refreshSessionModels(sessionId: string | null = sessionRef.current): Promise { + if (sessionId === null) { + setSessionModels(null) + setShowModelPicker(false) + return + } + const fromSettings = await loadModelDirectoryFromSettings().catch(() => null) + try { + const result = await api.rpc<{ + current?: { provider: string; model: string; reasoningEffort?: string } + routable?: boolean + groups?: Array<{ id: string; name: string; models: Array<{ id: string; name: string }> }> + }>('session.models', { sessionId }) + if (sessionRef.current !== sessionId) return + const sessionOptions = (result.groups ?? []).flatMap((group) => group.models.map((model) => ({ + provider: group.id, + providerName: group.name, + id: model.id, + name: model.name, + }))) + const options = sessionOptions.length > 0 ? sessionOptions : (fromSettings?.options ?? []) + setSessionModels({ + current: result.current + ?? fromSettings?.current + ?? (options[0] === undefined + ? null + : { provider: options[0].provider, model: options[0].id }), + routable: result.routable ?? fromSettings?.routable ?? null, + options, + }) + } catch { + if (sessionRef.current !== sessionId) return + // Host-wide settings still list configured relays before a session exists. + setSessionModels(fromSettings) + } + } + + /** Build a composer directory from saved llm-pi-ai routes (works without a Host session). */ + async function loadModelDirectoryFromSettings(): Promise { + const described = await api.rpc<{ + namespaces?: Array<{ ns: string; value?: Record }> + }>('settings.describe', {}) + const providers = (described.namespaces?.find((candidate) => candidate.ns === 'llm-pi-ai') + ?.value?.providers ?? {}) as Record + }> + const defaults = (described.namespaces?.find((candidate) => candidate.ns === 'agent-default-model') + ?.value ?? {}) as { provider?: unknown; model?: unknown; reasoningEffort?: unknown } + const options = Object.entries(providers).flatMap(([provider, route]) => ( + (route.models ?? []).map((model) => ({ + provider, + providerName: typeof route.displayName === 'string' && route.displayName !== '' + ? route.displayName + : provider, + id: model.id, + name: typeof model.name === 'string' && model.name !== '' ? model.name : model.id, + })) + )).filter((option) => option.id !== '') + if (options.length === 0) return { current: null, options: [], routable: false } + const defaultProvider = typeof defaults.provider === 'string' ? defaults.provider : '' + const defaultModel = typeof defaults.model === 'string' ? defaults.model : '' + const matched = options.find((option) => ( + option.provider === defaultProvider && option.id === defaultModel + )) ?? options[0]! + return { + current: { + provider: matched.provider, + model: matched.id, + ...(typeof defaults.reasoningEffort === 'string' && defaults.reasoningEffort !== '' + ? { reasoningEffort: defaults.reasoningEffort } + : {}), + }, + options, + routable: true, + } + } + + /** Switch the next assembled turn onto the chosen provider/model. */ + async function selectSessionModel(option: SessionModelOption): Promise { + const sessionId = sessionRef.current + if (sessionId === null || modelSelecting || busy || working) return + setShowModelPicker(false) + if (sessionModels?.current?.provider === option.provider + && sessionModels.current.model === option.id) return + setModelSelecting(true) + setError(null) + try { + const result = await api.rpc<{ + selected: { provider: string; model: string; reasoningEffort?: string } + }>('session.selectModel', { + sessionId, + provider: option.provider, + model: option.id, + }) + if (sessionRef.current !== sessionId) return + setSessionModels((current) => current === null + ? { + current: result.selected, + routable: true, + options: [option], + } + : { + ...current, + current: result.selected, + routable: true, + }) + } catch (cause) { + if (sessionRef.current === sessionId) { + setError(cause instanceof Error ? cause.message : String(cause)) + } + } finally { + setModelSelecting(false) + } } /** Load the raw host index plus workspace archive state once. */ @@ -1189,6 +1347,7 @@ export function App(): React.JSX.Element { await api.setActiveSession(hinted) setSessionTitle(projectedSessionTitle(entry) ?? sessionDisplayTitle(entry)) applyHistory(hinted, history) + await refreshSessionModels(hinted) return } } catch { @@ -1240,6 +1399,7 @@ export function App(): React.JSX.Element { sessionRef.current = entry.sessionId setSessionTitle(projectedSessionTitle(entry) ?? sessionDisplayTitle(entry)) await refreshHistory(entry.sessionId) + await refreshSessionModels(entry.sessionId) } catch (cause) { if (sessionTransitionRef.current === transition) { setError(cause instanceof Error ? cause.message : String(cause)) @@ -1288,6 +1448,7 @@ export function App(): React.JSX.Element { setSessionTitle(sessionId) if (history !== undefined) applyHistory(sessionId, history) else setError(historyError instanceof Error ? historyError.message : String(historyError)) + await refreshSessionModels(sessionId) } finally { finishSessionTransition(transition) } @@ -1300,6 +1461,8 @@ export function App(): React.JSX.Element { try { sessionRef.current = null setSessionTitle(null) + setSessionModels(null) + setShowModelPicker(false) prepareSessionSwitch(false) await createSession(transition) } catch (cause) { @@ -1409,6 +1572,8 @@ export function App(): React.JSX.Element { ), ...(clientTimeZone === undefined ? {} : { clientTimeZone }), }) + // Deferred sessions materialize on the first prompt; refresh so chips appear. + void refreshSessionModels(id) if (submittedSelection !== null) { // Keep the background authoritative while the prompt is in flight. // Conditional clearing cannot consume a newer highlight captured in @@ -1460,6 +1625,7 @@ export function App(): React.JSX.Element { if (!relaySaved) return await api.updateSettings(settings) setShowSettings(false) + await refreshSessionModels(sessionRef.current) } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)) } @@ -1736,6 +1902,30 @@ export function App(): React.JSX.Element { // 状态栏只显示连接状态;快照上限是技术细节,在设置页说明(见 hint)。 const statusText = copy.status[state] const sessionMenuTitle = sessionTitle ?? copy.app.newSession + const currentModelOption = sessionModels?.options.find((option) => ( + option.provider === sessionModels.current?.provider + && option.id === sessionModels.current.model + )) + const currentModelLabel = currentModelOption?.name + ?? (sessionModels !== null && sessionModels.options.length === 0 + ? copy.app.noModelsConfigured + : copy.app.selectModel) + const modelMenuGroups = (() => { + const groups: Array<{ provider: string; providerName: string; models: SessionModelOption[] }> = [] + for (const option of sessionModels?.options ?? []) { + const existing = groups.find((group) => group.provider === option.provider) + if (existing === undefined) { + groups.push({ + provider: option.provider, + providerName: option.providerName, + models: [option], + }) + continue + } + existing.models.push(option) + } + return groups + })() const approvalDialog = !approvalReadyForSession(queuedApproval, sessionRef.current, sessionChanging) ? null : @@ -1986,7 +2176,10 @@ export function App(): React.JSX.Element { aria-expanded={showTextSize} aria-label={copy.textSize.open} title={copy.textSize.open}> -
@@ -2190,6 +2383,52 @@ export function App(): React.JSX.Element { )} + {state === 'connected' ? ( +
+ + {showModelPicker && sessionModels !== null && sessionModels.options.length > 0 ? ( +
+ {modelMenuGroups.map((group) => ( +
+ {modelMenuGroups.length > 1 ? ( +
{group.providerName}
+ ) : null} + {group.models.map((option) => { + const selected = sessionModels.current?.provider === option.provider + && sessionModels.current.model === option.id + return ( + + ) + })} +
+ ))} +
+ ) : null} +
+ ) : null} {approvalDialog} ) diff --git a/extensions/dsh-browser/src/panel/strings.ts b/extensions/dsh-browser/src/panel/strings.ts index 4689aa3ac..8ec8c22df 100644 --- a/extensions/dsh-browser/src/panel/strings.ts +++ b/extensions/dsh-browser/src/panel/strings.ts @@ -181,6 +181,8 @@ export interface PanelCopy { disconnectedPlaceholder: string composerHelp: string sendMessage: string + selectModel: string + noModelsConfigured: string stopTurn: string stoppingTurn: string addImages: string @@ -412,6 +414,8 @@ const EN: PanelCopy = { disconnectedPlaceholder: 'Connect to dsh to get started', composerHelp: 'Enter to send · Shift + Enter for a new line', sendMessage: 'Send message', + selectModel: 'Model', + noModelsConfigured: 'No models yet — Host has no routable models.', stopTurn: 'Stop generating', stoppingTurn: 'Stopping…', addImages: 'Add images', @@ -643,6 +647,8 @@ const ZH: PanelCopy = { disconnectedPlaceholder: '连接 dsh 后即可开始', composerHelp: 'Enter 发送 · Shift + Enter 换行', sendMessage: '发送消息', + selectModel: '模型', + noModelsConfigured: '暂无模型 — Host 尚未提供可用模型。', stopTurn: '停止生成', stoppingTurn: '正在停止…', addImages: '添加图片', diff --git a/extensions/dsh-browser/src/panel/styles.css b/extensions/dsh-browser/src/panel/styles.css index cce073ac5..7e2efece4 100644 --- a/extensions/dsh-browser/src/panel/styles.css +++ b/extensions/dsh-browser/src/panel/styles.css @@ -1550,6 +1550,138 @@ textarea:focus-visible { cursor: not-allowed; } +.composer-model { + position: relative; + display: flex; + min-width: 0; + margin-top: 8px; + padding: 0 2px; +} + +.composer-model-trigger { + display: inline-flex; + max-width: min(280px, 100%); + min-height: 28px; + align-items: center; + gap: 4px; + padding: 0 8px 0 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--canvas); + color: var(--muted); + cursor: pointer; + font: inherit; + font-size: calc(11px * var(--ui-scale)); + font-weight: 600; + line-height: 1.2; +} + +.composer-model-trigger > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer-model-trigger svg { + width: 13px; + height: 13px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; + transition: transform 150ms ease; +} + +.composer-model-trigger:hover:not(:disabled), +.composer-model-trigger[aria-expanded="true"] { + border-color: var(--line-strong); + background: var(--surface); + color: var(--ink); +} + +.composer-model-trigger[aria-expanded="true"] svg { + transform: rotate(180deg); +} + +.composer-model-trigger:disabled { + opacity: .55; + cursor: default; +} + +.composer-model-menu { + position: absolute; + left: 2px; + bottom: calc(100% + 6px); + z-index: 40; + display: flex; + min-width: max(200px, 100%); + max-width: min(320px, calc(100vw - 24px)); + max-height: min(280px, 42vh); + flex-direction: column; + gap: 2px; + overflow: auto; + padding: 4px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + box-shadow: 0 10px 28px rgba(18, 28, 56, 0.12); +} + +.composer-model-group + .composer-model-group { + margin-top: 2px; + padding-top: 2px; + border-top: 1px solid var(--line); +} + +.composer-model-group-title { + color: var(--faint); + font-size: calc(10px * var(--ui-scale)); + font-weight: 600; + line-height: 1.3; + padding: 5px 8px 3px; +} + +.composer-model-menu button { + display: flex; + width: 100%; + min-height: 32px; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 6px 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--ink); + cursor: pointer; + font: inherit; + font-size: calc(12px * var(--ui-scale)); + font-weight: 560; + line-height: 1.3; + text-align: left; +} + +.composer-model-menu button > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.composer-model-menu button:hover, +.composer-model-menu button:focus-visible { + background: var(--blue-wash); + color: var(--blue-ink); + outline: none; +} + +.composer-model-menu button.isSelected { + background: var(--blue-wash); + color: var(--blue-ink); + font-weight: 680; +} + .composer-actions { display: flex; min-height: 37px; @@ -2718,6 +2850,34 @@ textarea:focus-visible { gap: 3px; } +.relay-profile label.setting-toggle { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 4px 0; + border-bottom: 0; + cursor: pointer; +} + +.relay-profile label.setting-toggle > .setting-toggle-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 3px; +} + +.relay-profile label.setting-toggle > .setting-toggle-copy > strong { + color: var(--ink); + font-size: calc(10.5px * var(--ui-scale)); + font-weight: 640; + line-height: 1.35; +} + +.relay-profile label.setting-toggle > .setting-toggle-copy > small { + text-align: left; +} + .relay-profile label > span { color: var(--ink); font-size: calc(10.5px * var(--ui-scale)); diff --git a/extensions/dsh-browser/tests/panel-styles.spec.ts b/extensions/dsh-browser/tests/panel-styles.spec.ts index 92d943b21..b1ac3c797 100644 --- a/extensions/dsh-browser/tests/panel-styles.spec.ts +++ b/extensions/dsh-browser/tests/panel-styles.spec.ts @@ -22,17 +22,16 @@ describe('panel layout styles', () => { expect(settingsChildrenRule).toMatch(/(?:^|\n)\s*flex-shrink:\s*0;/) }) - it('floats a scroll-to-bottom control over the conversation pane', () => { + it('places an upward model menu on its own row under the composer', () => { const styles = readFileSync(`${process.cwd()}/src/panel/styles.css`, 'utf8') - const paneRule = styles.match(/\.messages-pane\s*\{([^}]*)\}/)?.[1] - const buttonRule = styles.match(/\.scroll-to-bottom\s*\{([^}]*)\}/)?.[1] + const modelRule = styles.match(/\.composer-model\s*\{([^}]*)\}/)?.[1] + const menuRule = styles.match(/\.composer-model-menu\s*\{([^}]*)\}/)?.[1] - expect(paneRule).toBeDefined() - expect(paneRule).toMatch(/(?:^|\n)\s*position:\s*relative;/) - expect(paneRule).toMatch(/(?:^|\n)\s*flex:\s*1;/) - expect(buttonRule).toBeDefined() - expect(buttonRule).toMatch(/(?:^|\n)\s*position:\s*absolute;/) - expect(buttonRule).toMatch(/(?:^|\n)\s*right:\s*16px;/) - expect(buttonRule).toMatch(/(?:^|\n)\s*bottom:\s*12px;/) + expect(modelRule).toBeDefined() + expect(modelRule).toMatch(/(?:^|\n)\s*position:\s*relative;/) + expect(modelRule).toMatch(/(?:^|\n)\s*margin-top:\s*8px;/) + expect(menuRule).toBeDefined() + expect(menuRule).toMatch(/(?:^|\n)\s*position:\s*absolute;/) + expect(menuRule).toMatch(/(?:^|\n)\s*bottom:\s*calc\(100% \+ 6px\);/) }) }) diff --git a/packages/browser/bridge-browser/src/remote-host-api.ts b/packages/browser/bridge-browser/src/remote-host-api.ts index af4e406d4..532981914 100644 --- a/packages/browser/bridge-browser/src/remote-host-api.ts +++ b/packages/browser/bridge-browser/src/remote-host-api.ts @@ -622,6 +622,15 @@ function invokeTarget(call: HostRpcCall): InvokeTarget | { readonly error: HostR switch (call.method) { case 'session.list': return { namespace: 'session', method: 'list', args: { _request: call.payload } } + case 'session.models': + // Host Typert exposes session/modelCatalog (no args). Adapt ModelCatalog + // into the panel's { current, routable, groups } directory shape. + return { + namespace: 'session', + method: 'modelCatalog', + args: {}, + adapt: adaptModelCatalog, + } case 'session.create': case 'session.selectModel': case 'session.attachment': @@ -885,6 +894,43 @@ function sessionIdOf(payload: unknown): string | undefined { : undefined } +/** Map Host ModelCatalog into the extension's session.models directory shape. */ +function adaptModelCatalog(value: unknown): unknown { + if (!isRecord(value)) return value + const selection = modelSelectionOf(value.default) + const groups = Array.isArray(value.groups) ? value.groups : [] + const failures = Array.isArray(value.failures) ? value.failures : [] + const routableProviders = Array.isArray(value.routableProviders) + ? value.routableProviders.filter((entry): entry is string => typeof entry === 'string') + : [] + const current = selection ?? { provider: 'none', model: 'none' } + return { + current, + routable: selection !== undefined && routableProviders.includes(selection.provider), + groups, + failures, + } +} + +function modelSelectionOf(value: unknown): { + provider: string + model: string + reasoningEffort?: string +} | undefined { + if (!isRecord(value)) return undefined + const provider = typeof value.provider === 'string' ? value.provider.trim() : '' + const model = typeof value.model === 'string' ? value.model.trim() : '' + if (provider === '' || model === '') return undefined + const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== '' + ? value.reasoningEffort.trim() + : undefined + return { + provider, + model, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + } +} + function badRequest(message: string): HostRpcResult { return { ok: false, error: badRequestFailure(message) } } diff --git a/packages/browser/bridge-browser/src/session-deferral.ts b/packages/browser/bridge-browser/src/session-deferral.ts index 9f0dbe103..896586894 100644 --- a/packages/browser/bridge-browser/src/session-deferral.ts +++ b/packages/browser/bridge-browser/src/session-deferral.ts @@ -9,6 +9,11 @@ * the first `session.prompt` for that id. Abandoned provisional ids are * pruned after {@link PROVISIONAL_TTL_MS}. * + * Provisional sessions also answer `session.models` from the host-wide + * `session.modelCatalog` (via the Host API adapter, plus a pending switch) + * and remember `session.selectModel` until materialization, so the composer can + * show a model switcher before the first message. + * * @module @yuxianglin/dsh-bridge-browser/src/session-deferral */ @@ -19,10 +24,18 @@ import { isRecord } from './host-api.ts' /** Provisional entries older than this are dropped on the next create. */ const PROVISIONAL_TTL_MS = 30 * 60_000 +interface ModelSelection { + provider: string + model: string + reasoningEffort?: string +} + interface ProvisionalEntry { /** The original create payload, replayed at materialization (keeps cwd/workspaceId). */ payload: Record createdAt: number + /** Composer switch chosen before the session exists on the Host. */ + selection?: ModelSelection } /** @@ -81,6 +94,29 @@ export function withSessionDeferral( }, } } + if (call.method === 'session.models') { + const sessionId = sessionIdOf(call.payload) + if (sessionId === undefined || !provisional.has(sessionId)) return api.call(call) + return provisionalModels(api, provisional.get(sessionId)!, call.signal) + } + if (call.method === 'session.selectModel') { + const sessionId = sessionIdOf(call.payload) + if (sessionId === undefined || !provisional.has(sessionId)) return api.call(call) + const entry = provisional.get(sessionId)! + const selected = selectionOf(call.payload) + if (selected === undefined) { + return { + ok: false, + error: { + code: 'bad-request', + message: 'session.selectModel requires provider and model', + details: {}, + }, + } + } + entry.selection = selected + return { ok: true, value: { selected: { ...selected } } } + } if (call.method !== 'session.prompt') return api.call(call) const sessionId = sessionIdOf(call.payload) if (sessionId === undefined) return api.call(call) @@ -102,7 +138,20 @@ export function withSessionDeferral( } const created = await pending if (!created.ok) return created + const selection = entry.selection provisional.delete(sessionId) + if (selection !== undefined) { + try { + await api.call({ + rpcId: crypto.randomUUID(), + method: 'session.selectModel', + payload: { sessionId, ...selection }, + signal: call.signal, + }) + } catch { + // The prompt still proceeds; the Host keeps its deployment default. + } + } return api.call(call) }, events: signal => api.events(signal), @@ -114,3 +163,122 @@ function sessionIdOf(payload: unknown): string | undefined { if (!isRecord(payload)) return undefined return typeof payload.sessionId === 'string' ? payload.sessionId : undefined } + +function selectionOf(payload: unknown): ModelSelection | undefined { + if (!isRecord(payload)) return undefined + const provider = typeof payload.provider === 'string' ? payload.provider.trim() : '' + const model = typeof payload.model === 'string' ? payload.model.trim() : '' + if (provider === '' || model === '') return undefined + const reasoningEffort = typeof payload.reasoningEffort === 'string' && payload.reasoningEffort.trim() !== '' + ? payload.reasoningEffort.trim() + : undefined + return { + provider, + model, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + } +} + +/** Build a session.models-shaped answer from the host catalog for a provisional id. */ +async function provisionalModels( + api: BrowserHostApi, + entry: ProvisionalEntry, + signal: AbortSignal, +): Promise { + // Inner API (not the deferral wrapper): session.models → session/modelCatalog. + const catalog = await api.call({ + rpcId: crypto.randomUUID(), + method: 'session.models', + payload: {}, + signal, + }) + if (!catalog.ok) return catalog + const groups = isRecord(catalog.value) && Array.isArray(catalog.value.groups) + ? catalog.value.groups + : [] + const failures = isRecord(catalog.value) && Array.isArray(catalog.value.failures) + ? catalog.value.failures + : [] + const catalogCurrent = isRecord(catalog.value) ? modelSelectionOf(catalog.value.current) : undefined + const current = entry.selection + ?? catalogCurrent + ?? await defaultSelection(api, signal) + ?? firstCatalogSelection(groups) + if (current === undefined) { + return { + ok: true, + value: { + current: { provider: 'none', model: 'none' }, + routable: false, + groups, + failures, + }, + } + } + return { + ok: true, + value: { + current: { ...current }, + routable: true, + groups, + failures, + }, + } +} + +function modelSelectionOf(value: unknown): ModelSelection | undefined { + if (!isRecord(value)) return undefined + const provider = typeof value.provider === 'string' ? value.provider.trim() : '' + const model = typeof value.model === 'string' ? value.model.trim() : '' + if (provider === '' || model === '') return undefined + const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== '' + ? value.reasoningEffort.trim() + : undefined + return { + provider, + model, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + } +} + +async function defaultSelection( + api: BrowserHostApi, + signal: AbortSignal, +): Promise { + const described = await api.call({ + rpcId: crypto.randomUUID(), + method: 'settings.describe', + payload: {}, + signal, + }) + if (!described.ok || !isRecord(described.value) || !Array.isArray(described.value.namespaces)) { + return undefined + } + const defaults = described.value.namespaces.find((candidate) => ( + isRecord(candidate) && candidate.ns === 'agent-default-model' + )) + const value = isRecord(defaults) && isRecord(defaults.value) ? defaults.value : undefined + if (value === undefined) return undefined + const provider = typeof value.provider === 'string' ? value.provider.trim() : '' + const model = typeof value.model === 'string' ? value.model.trim() : '' + if (provider === '' || model === '') return undefined + const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== '' + ? value.reasoningEffort.trim() + : undefined + return { + provider, + model, + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + } +} + +function firstCatalogSelection(groups: unknown[]): ModelSelection | undefined { + for (const group of groups) { + if (!isRecord(group) || typeof group.id !== 'string' || !Array.isArray(group.models)) continue + for (const model of group.models) { + if (!isRecord(model) || typeof model.id !== 'string' || model.id.trim() === '') continue + return { provider: group.id, model: model.id } + } + } + return undefined +} diff --git a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts index 37e7b1a80..f24cbe2e9 100644 --- a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts +++ b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts @@ -110,12 +110,37 @@ describe('dsh 0.1.5 Remote Host adapter', () => { invoke: async ({ namespace, method }) => { if (`${namespace}/${method}` === 'credentials/describe') return { TOKEN: { configured: true } } if (`${namespace}/${method}` === 'llm/discoverModels') return [{ id: 'deepseek-chat' }] + if (`${namespace}/${method}` === 'session/modelCatalog') { + return { + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: ['deepseek-official'], + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + } + } return { accepted: true } }, }) await api.call(call('session.list', {})) await api.call(call('session.prompt', { sessionId: 'session-1', mode: 'queue', content: [] }, 'prompt-id')) + await expect(api.call(call('session.models', { sessionId: 'session-1' }))).resolves.toEqual({ + ok: true, + value: { + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + }, + }) await api.call(call('settings.mutate', { ns: 'llm-pi-ai', ops: [] })) await expect(api.call(call('credentials.describe', { refs: ['TOKEN'] }))).resolves.toEqual({ ok: true, value: { credentials: { TOKEN: { configured: true } } }, @@ -133,9 +158,14 @@ describe('dsh 0.1.5 Remote Host adapter', () => { args: { request: { requestId: 'prompt-id', sessionId: 'session-1', mode: 'queue', content: [] } }, })) expect(invoke).toHaveBeenNthCalledWith(3, expect.objectContaining({ + namespace: 'session', + method: 'modelCatalog', + args: {}, + })) + expect(invoke).toHaveBeenNthCalledWith(4, expect.objectContaining({ namespace: 'settings', method: 'mutate', args: { ns: 'llm-pi-ai', ops: [] }, })) - expect(invoke).toHaveBeenNthCalledWith(5, expect.objectContaining({ + expect(invoke).toHaveBeenNthCalledWith(6, expect.objectContaining({ namespace: 'llm', method: 'discoverModels', args: { diff --git a/packages/browser/bridge-browser/tests/session-deferral.spec.ts b/packages/browser/bridge-browser/tests/session-deferral.spec.ts index eb2b1608d..5a535658c 100644 --- a/packages/browser/bridge-browser/tests/session-deferral.spec.ts +++ b/packages/browser/bridge-browser/tests/session-deferral.spec.ts @@ -153,6 +153,66 @@ describe('withSessionDeferral', () => { expect(sessionPrompt).toHaveBeenCalledOnce() }) + it('serves provisional model catalogs from session.models and defers selectModel', async () => { + const { api, call } = apiHarness() + call.mockImplementation(async (request: HostRpcCall): Promise => { + if (request.method === 'session.create') { + return { ok: true, value: { sessionId: (request.payload as { sessionId: string }).sessionId } } + } + if (request.method === 'session.prompt') return { ok: true, value: { accepted: true } } + if (request.method === 'session.models') { + return { + ok: true, + value: { + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + }, + } + } + if (request.method === 'session.selectModel') { + return { ok: true, value: { selected: request.payload } } + } + return { ok: false, error: { code: 'not-found', message: request.method, details: {} } } + }) + const wrapped = withSessionDeferral(api, true) + const id = await provisionalId(wrapped) + + await expect(wrapped.call(request('session.models', { sessionId: id }))).resolves.toEqual({ + ok: true, + value: { + current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routable: true, + groups: [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + }, + }) + + await expect(wrapped.call(request('session.selectModel', { + sessionId: id, + provider: 'deepseek-official', + model: 'deepseek-v4-pro', + }))).resolves.toEqual({ + ok: true, + value: { selected: { provider: 'deepseek-official', model: 'deepseek-v4-pro' } }, + }) + + await wrapped.call(request('session.prompt', { sessionId: id, mode: 'queue', content: [] }, 'prompt')) + expect(call).toHaveBeenCalledWith(expect.objectContaining({ + method: 'session.selectModel', + payload: { sessionId: id, provider: 'deepseek-official', model: 'deepseek-v4-pro' }, + })) + }) + it('prunes stale provisional entries and returns the original API when disabled', async () => { vi.useFakeTimers() const { api, sessionHistory } = apiHarness() From aff85fb6e213504939004a6aefb89c7895aff11a Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Thu, 1 Oct 2026 01:34:30 -0400 Subject: [PATCH 4/4] fix: preserve session model choices and automatic scrolling --- extensions/dsh-browser/src/panel/App.tsx | 8 +- .../tests/panel-session-transition.spec.ts | 91 +++++++++++++++++++ .../bridge-browser/src/remote-host-api.ts | 35 ++++--- .../bridge-browser/src/session-deferral.ts | 56 +++++++----- .../tests/remote-host-api.spec.ts | 35 ++++++- .../tests/session-deferral.spec.ts | 58 ++++++++++++ scripts/fixtures/runtime-probe.mjs | 3 + scripts/smoke-runtime.mjs | 5 +- 8 files changed, 253 insertions(+), 38 deletions(-) diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index 8e6baadea..6d3d93602 100644 --- a/extensions/dsh-browser/src/panel/App.tsx +++ b/extensions/dsh-browser/src/panel/App.tsx @@ -7,7 +7,7 @@ * @module */ -import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { BRIDGE_SESSION_PURGE_METHOD, DEFAULT_SNAPSHOT_MAX_CHARS } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import type { BridgeCaps } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import type { ServerFrame } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' @@ -887,9 +887,11 @@ export function App(): React.JSX.Element { }, [queuedApproval?.id, queuedApproval?.sessionId, sessionChanging, state]) // Stick to the newest row only while the user is already near the bottom. - useEffect(() => { + useLayoutEffect(() => { if (!atBottomRef.current) return - scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) + // Scroll before ResizeObserver measures new content, without intermediate + // smooth-scroll events disabling follow while content keeps growing. + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'instant' }) }, [rows, streamRow, working]) // Sibling chrome (session picker, question card, errors, attachments) can diff --git a/extensions/dsh-browser/tests/panel-session-transition.spec.ts b/extensions/dsh-browser/tests/panel-session-transition.spec.ts index c7dbe8c45..9c2c4aa1f 100644 --- a/extensions/dsh-browser/tests/panel-session-transition.spec.ts +++ b/extensions/dsh-browser/tests/panel-session-transition.spec.ts @@ -14,6 +14,7 @@ vi.mock('../src/panel/api.ts', () => ({ })) import { App } from '../src/panel/App.tsx' +import { createRemoteHostApi } from '@yuxianglin/dsh-bridge-browser/src/remote-host-api.ts' describe('panel session transitions', () => { let root: Root @@ -89,6 +90,96 @@ describe('panel session transitions', () => { }) } + it('shows the saved Session model and allows switching to the deployment default', async () => { + let selected = { provider: 'provider', model: 'saved-model' } + const host = createRemoteHostApi({ + invoke: async ({ method, args }) => { + if (method === 'modelCatalog') return { + default: { provider: 'provider', model: 'default-model' }, + routableProviders: ['provider'], + groups: [{ id: 'provider', name: 'Provider', models: [ + { id: 'default-model', name: 'Default model' }, { id: 'saved-model', name: 'Saved model' }, + ] }], + failures: [], + } + if (method === 'projections') return { asOfSeq: 7, values: { modelSelection: { next: selected } } } + if (method === 'selectModel') { + const request = args.request as { provider: string; model: string } + selected = { provider: request.provider, model: request.model } + return { selected } + } + throw new Error(`Unexpected Host method: ${method}`) + }, + wireStream: { + open: async () => { throw new Error('unexpected stream') }, + failure: (error) => ({ code: 'internal', message: String(error), details: {} }), + }, + }, { createSharedFetchHandler: () => ({ fetch: async () => { throw new Error('unexpected fetch') } }) }) + const original = rpc.getMockImplementation()! + rpc.mockImplementation(async (method, payload) => { + if (method !== 'session.models' && method !== 'session.selectModel') return original(method, payload) + const result = await host.call({ rpcId: method, method, payload, signal: new AbortController().signal }) + if (!result.ok) throw new Error(result.error.message) + return result.value + }) + await renderConnected(null) + const trigger = document.querySelector('.composer-model-trigger')! + expect(trigger.textContent).toContain('Saved model') + await act(async () => { trigger.click() }) + const option = [...document.querySelectorAll('[role="option"]')] + .find((button) => button.textContent === 'Default model')! + await act(async () => { option.click() }) + expect(rpc).toHaveBeenCalledWith('session.selectModel', { + sessionId: 'session-current', provider: 'provider', model: 'default-model', + }) + expect(selected.model).toBe('default-model') + expect(trigger.textContent).toContain('Default model') + }) + + it('keeps following arriving rows and stops only when the reader scrolls up', async () => { + await renderConnected(null) + const messages = document.querySelector('.messages')! + let scrollHeight = 1000 + let scrollTop = 400 + Object.defineProperties(messages, { + scrollHeight: { get: () => scrollHeight }, + clientHeight: { get: () => 600 }, + scrollTop: { get: () => scrollTop }, + }) + // Model the intermediate scroll event produced by CSS smooth scrolling. + messages.scrollTo = vi.fn((options: ScrollToOptions) => { + const destination = Math.min(options.top ?? 0, scrollHeight - 600) + scrollTop = options.behavior === 'instant' ? destination : scrollTop + 1 + messages.dispatchEvent(new Event('scroll')) + }) as typeof messages.scrollTo + const append = async (seq: number): Promise => { + await act(async () => { + scrollHeight += 200 + onEvent?.({ t: 'event', frame: { rpcId: `row-${seq}`, method: 'session/event', payload: { + sessionId: 'session-current', event: { type: 'assistant/message', seq, surfaceOp: 'append', data: { + turn: seq, step: 0, message: { content: [{ type: 'text', text: `Message ${seq}` }] }, + } }, + } } }) + }) + } + await append(1) + await append(2) + expect(scrollTop).toBe(scrollHeight - 600) + expect(document.querySelector('.scroll-to-bottom')).toBeNull() + + await act(async () => { + scrollTop -= 200 + messages.dispatchEvent(new Event('scroll')) + }) + const readingPosition = scrollTop + await append(3) + expect(scrollTop).toBe(readingPosition) + const jump = document.querySelector('.scroll-to-bottom')! + expect(jump).not.toBeNull() + await act(async () => { jump.click() }) + expect(messages.scrollTo).toHaveBeenLastCalledWith({ top: scrollHeight, behavior: 'smooth' }) + }) + it('keeps the reconnect stream suffix when history resolves late, then shows only the durable settlement', async () => { let finishHistory: ((history: unknown) => void) | undefined const historyPromise = new Promise((resolve) => { finishHistory = resolve }) diff --git a/packages/browser/bridge-browser/src/remote-host-api.ts b/packages/browser/bridge-browser/src/remote-host-api.ts index e6da21996..e620d517e 100644 --- a/packages/browser/bridge-browser/src/remote-host-api.ts +++ b/packages/browser/bridge-browser/src/remote-host-api.ts @@ -126,6 +126,7 @@ class RemoteHostApi implements BrowserHostApi { async call(call: HostRpcCall): Promise { if (call.method === 'session.history') return this.sessionHistory(call) + if (call.method === 'session.models') return this.sessionModels(call) if (call.method === 'workspace.list') return this.workspaceList(call) const target = invokeTarget(call) @@ -225,6 +226,27 @@ class RemoteHostApi implements BrowserHostApi { } } + /** Combine the deployment catalog with the Session's durable next selection. */ + private async sessionModels(call: HostRpcCall): Promise { + if (!isRecord(call.payload)) return badRequest('session.models payload must be an object') + const sessionId = sessionIdOf(call.payload) + try { + const catalog = await this.gateway.invoke({ + namespace: 'session', method: 'modelCatalog', args: {}, signal: call.signal, + }) + // Provisional Sessions request only the catalog and overlay their pending choice. + const projections = sessionId === undefined ? undefined : await this.gateway.invoke({ + namespace: 'session', method: 'projections', args: { request: { sessionId } }, signal: call.signal, + }) + const values = isRecord(projections) ? projections.values : undefined + const modelSelection = isRecord(values) ? values.modelSelection : undefined + const next = isRecord(modelSelection) ? modelSelectionOf(modelSelection.next) : undefined + return { ok: true, value: adaptModelCatalog(catalog, next) } + } catch (error: unknown) { + return { ok: false, error: this.failure(error) } + } + } + /** * Resolve a Host-legal throughSeq for older history pages. * Never invent Number.MAX_SAFE_INTEGER — session/page rejects tips past the log cursor. @@ -658,15 +680,6 @@ function invokeTarget(call: HostRpcCall): InvokeTarget | { readonly error: HostR switch (call.method) { case 'session.list': return { namespace: 'session', method: 'list', args: { _request: call.payload } } - case 'session.models': - // Host Typert exposes session/modelCatalog (no args). Adapt ModelCatalog - // into the panel's { current, routable, groups } directory shape. - return { - namespace: 'session', - method: 'modelCatalog', - args: {}, - adapt: adaptModelCatalog, - } case 'session.create': case 'session.selectModel': case 'session.attachment': @@ -931,9 +944,9 @@ function sessionIdOf(payload: unknown): string | undefined { } /** Map Host ModelCatalog into the extension's session.models directory shape. */ -function adaptModelCatalog(value: unknown): unknown { +function adaptModelCatalog(value: unknown, next?: ReturnType): unknown { if (!isRecord(value)) return value - const selection = modelSelectionOf(value.default) + const selection = next ?? modelSelectionOf(value.default) const groups = Array.isArray(value.groups) ? value.groups : [] const failures = Array.isArray(value.failures) ? value.failures : [] const routableProviders = Array.isArray(value.routableProviders) diff --git a/packages/browser/bridge-browser/src/session-deferral.ts b/packages/browser/bridge-browser/src/session-deferral.ts index 896586894..ba7eb673a 100644 --- a/packages/browser/bridge-browser/src/session-deferral.ts +++ b/packages/browser/bridge-browser/src/session-deferral.ts @@ -34,6 +34,8 @@ interface ProvisionalEntry { /** The original create payload, replayed at materialization (keeps cwd/workspaceId). */ payload: Record createdAt: number + /** Keep failed model selections retryable after the Host Session was created. */ + materialized?: boolean /** Composer switch chosen before the session exists on the Host. */ selection?: ModelSelection } @@ -62,13 +64,41 @@ export function withSessionDeferral( const prune = (): void => { const cutoff = Date.now() - PROVISIONAL_TTL_MS for (const [id, entry] of provisional) { - if (entry.createdAt < cutoff) provisional.delete(id) + if (entry.createdAt < cutoff && !entry.materialized && !materializing.has(id)) provisional.delete(id) } } const mintedId = (payload: Record): string => typeof payload.sessionId === 'string' ? payload.sessionId : `session-${crypto.randomUUID()}` + async function materialize(sessionId: string, entry: ProvisionalEntry, signal: AbortSignal): Promise { + if (!entry.materialized) { + const created = await api.call({ + rpcId: crypto.randomUUID(), + method: 'session.create', + payload: { ...entry.payload, sessionId }, + signal, + }) + if (!created.ok) return created + entry.materialized = true + } + // All prompts share this barrier, including those arriving during selection. + // A changed choice must also succeed before any queued prompt is admitted. + while (entry.selection !== undefined) { + const selection = entry.selection + const selected = await api.call({ + rpcId: crypto.randomUUID(), + method: 'session.selectModel', + payload: { sessionId, ...selection }, + signal, + }) + if (!selected.ok) return selected + if (entry.selection === selection) delete entry.selection + } + provisional.delete(sessionId) + return { ok: true, value: { sessionId } } + } + return { async call(call: HostRpcCall): Promise { if (call.method === 'session.create') { @@ -82,7 +112,8 @@ export function withSessionDeferral( } if (call.method === 'session.history') { const sessionId = sessionIdOf(call.payload) - if (sessionId === undefined || !provisional.has(sessionId)) return api.call(call) + if (sessionId === undefined || !provisional.has(sessionId) + || provisional.get(sessionId)!.materialized) return api.call(call) return { ok: true, value: { @@ -123,12 +154,7 @@ export function withSessionDeferral( const entry = provisional.get(sessionId) if (entry === undefined) return api.call(call) const existing = materializing.get(sessionId) - const pending = existing ?? api.call({ - rpcId: crypto.randomUUID(), - method: 'session.create', - payload: { ...entry.payload, sessionId }, - signal: call.signal, - }) + const pending = existing ?? materialize(sessionId, entry, call.signal) if (existing === undefined) { materializing.set(sessionId, pending) void pending.then( @@ -138,20 +164,6 @@ export function withSessionDeferral( } const created = await pending if (!created.ok) return created - const selection = entry.selection - provisional.delete(sessionId) - if (selection !== undefined) { - try { - await api.call({ - rpcId: crypto.randomUUID(), - method: 'session.selectModel', - payload: { sessionId, ...selection }, - signal: call.signal, - }) - } catch { - // The prompt still proceeds; the Host keeps its deployment default. - } - } return api.call(call) }, events: signal => api.events(signal), diff --git a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts index 517b976b4..471e1c82e 100644 --- a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts +++ b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts @@ -51,6 +51,39 @@ function harness(options: { } describe('dsh 0.2 Remote Host adapter', () => { + it.each([ + { next: { provider: 'session-provider', model: 'saved-model', reasoningEffort: 'high' }, routable: true }, + { next: { provider: 'removed-provider', model: 'saved-model' }, routable: false }, + { next: null, routable: true }, + ])('uses the Session projection before the deployment default: $next', async ({ next, routable }) => { + const deploymentDefault = { provider: 'default-provider', model: 'default-model' } + const { api, invoke } = harness({ + invoke: async ({ method }) => { + if (method === 'modelCatalog') return { + default: deploymentDefault, routableProviders: ['default-provider', 'session-provider'], groups: [], failures: [], + } + if (method === 'projections') return { asOfSeq: 7, values: { modelSelection: { next } } } + throw new Error(`Unexpected method: ${method}`) + }, + }) + await expect(api.call(call('session.models', { sessionId: 'session-1' }))).resolves.toEqual({ + ok: true, value: { current: next ?? deploymentDefault, routable, groups: [], failures: [] }, + }) + expect(invoke).toHaveBeenCalledWith(expect.objectContaining({ + namespace: 'session', method: 'projections', args: { request: { sessionId: 'session-1' } }, + })) + }) + + it('returns projection failures instead of presenting the deployment default as current', async () => { + const { api } = harness({ invoke: async ({ method }) => { + if (method === 'modelCatalog') return { default: { provider: 'p', model: 'default' } } + throw new Error('projection unavailable') + } }) + await expect(api.call(call('session.models', { sessionId: 'session-1' }))).resolves.toMatchObject({ + ok: false, error: { message: 'projection unavailable' }, + }) + }) + it('passes the signal in the fifth argument for DSH Desktop 2.x streams', async () => { const open = vi.fn(async (_endpoint: string, _payload: unknown, uplink: AsyncIterable, peer: unknown, signal: AbortSignal) => { expect(typeof uplink[Symbol.asyncIterator]).toBe('function') @@ -146,7 +179,7 @@ describe('dsh 0.2 Remote Host adapter', () => { await api.call(call('session.list', {})) await api.call(call('session.prompt', { sessionId: 'session-1', mode: 'queue', content: [] }, 'prompt-id')) - await expect(api.call(call('session.models', { sessionId: 'session-1' }))).resolves.toEqual({ + await expect(api.call(call('session.models', {}))).resolves.toEqual({ ok: true, value: { current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, diff --git a/packages/browser/bridge-browser/tests/session-deferral.spec.ts b/packages/browser/bridge-browser/tests/session-deferral.spec.ts index 5a535658c..3ce92819c 100644 --- a/packages/browser/bridge-browser/tests/session-deferral.spec.ts +++ b/packages/browser/bridge-browser/tests/session-deferral.spec.ts @@ -213,6 +213,64 @@ describe('withSessionDeferral', () => { })) }) + it.each(['business failure', 'exception'])('blocks prompts after a model %s and retries without recreating the Session', async (kind) => { + const { api, call, sessionCreate, sessionPrompt, sessionHistory } = apiHarness() + const original = call.getMockImplementation()! + const failure = { ok: false as const, error: { code: 'session/model-unavailable', message: 'model unavailable', details: {} } } + const select = vi.fn<() => Promise>() + if (kind === 'exception') select.mockRejectedValueOnce(new Error('model unavailable')) + else select.mockResolvedValueOnce(failure) + select.mockResolvedValue({ ok: true, value: { selected: { provider: 'p', model: 'chosen' } } }) + call.mockImplementation((request) => request.method === 'session.selectModel' ? select() : original(request)) + const wrapped = withSessionDeferral(api, true) + const id = await provisionalId(wrapped) + await wrapped.call(request('session.selectModel', { sessionId: id, provider: 'p', model: 'chosen' })) + + const first = wrapped.call(request('session.prompt', { sessionId: id }, 'first')) + if (kind === 'exception') await expect(first).rejects.toThrow('model unavailable') + else await expect(first).resolves.toEqual(failure) + expect(sessionPrompt).not.toHaveBeenCalled() + await wrapped.call(request('session.history', { sessionId: id })) + expect(sessionHistory).toHaveBeenCalledOnce() + + // Expiry must not discard the choice after creation has already succeeded. + vi.useFakeTimers() + vi.advanceTimersByTime(31 * 60_000) + await provisionalId(wrapped, 'another-session') + await expect(wrapped.call(request('session.prompt', { sessionId: id }, 'retry'))) + .resolves.toEqual({ ok: true, value: { accepted: true } }) + expect(sessionCreate).toHaveBeenCalledOnce() + expect(select).toHaveBeenCalledTimes(2) + expect(sessionPrompt).toHaveBeenCalledOnce() + }) + + it('holds concurrent prompts until the latest deferred choice is installed', async () => { + const { api, call, sessionCreate, sessionPrompt } = apiHarness() + const original = call.getMockImplementation()! + let release: ((result: HostRpcResult) => void) | undefined + const select = vi.fn(async (): Promise => ({ ok: true, value: {} })) + .mockImplementationOnce(() => new Promise((resolve) => { release = resolve })) + call.mockImplementation((request) => request.method === 'session.selectModel' ? select() : original(request)) + const wrapped = withSessionDeferral(api, true) + const id = await provisionalId(wrapped) + await wrapped.call(request('session.selectModel', { sessionId: id, provider: 'p', model: 'first-choice' })) + const first = wrapped.call(request('session.prompt', { sessionId: id }, 'first')) + await vi.waitFor(() => { expect(release).toBeDefined() }) + const second = wrapped.call(request('session.prompt', { sessionId: id }, 'second')) + await wrapped.call(request('session.selectModel', { sessionId: id, provider: 'p', model: 'latest-choice' })) + expect(sessionPrompt).not.toHaveBeenCalled() + release?.({ ok: true, value: {} }) + await Promise.all([first, second]) + expect(sessionCreate).toHaveBeenCalledOnce() + expect(select).toHaveBeenCalledTimes(2) + expect(sessionPrompt).toHaveBeenCalledTimes(2) + expect(call.mock.calls.filter(([request]) => request.method === 'session.selectModel') + .map(([request]) => request.payload)).toEqual([ + { sessionId: id, provider: 'p', model: 'first-choice' }, + { sessionId: id, provider: 'p', model: 'latest-choice' }, + ]) + }) + it('prunes stale provisional entries and returns the original API when disabled', async () => { vi.useFakeTimers() const { api, sessionHistory } = apiHarness() diff --git a/scripts/fixtures/runtime-probe.mjs b/scripts/fixtures/runtime-probe.mjs index 97c9ddc7e..77f49042f 100644 --- a/scripts/fixtures/runtime-probe.mjs +++ b/scripts/fixtures/runtime-probe.mjs @@ -38,6 +38,9 @@ export async function apply(ctx, config) { } ctx.on('session/created', async (session) => { if (session.id !== config.sessionId) return + // A durable Session choice must survive catalog refreshes and cold reads, + // independently of the deployment default. No model request is needed. + session.append('model/selection', { provider: 'smoke-provider', model: 'smoke-model' }) // Persist a blank session without an LLM call so restart exercises reads // from disk even though normal empty sessions may be deferred. // AgentLoop owns writes through its SessionHandle; the service flush diff --git a/scripts/smoke-runtime.mjs b/scripts/smoke-runtime.mjs index b4151e2f2..4fb70a225 100644 --- a/scripts/smoke-runtime.mjs +++ b/scripts/smoke-runtime.mjs @@ -176,6 +176,8 @@ try { await rpc(BRIDGE_INJECT_BROWSER_SNAPSHOT_METHOD, { sessionId, snapshot: 'Page: runtime smoke snapshot' }) const history = await rpc('session.history', { sessionId }) assert.ok(Array.isArray(history.events)) + const selectedModel = { provider: 'smoke-provider', model: 'smoke-model' } + assert.deepEqual((await rpc('session.models', { sessionId })).current, selectedModel) const injected = history.events.flatMap(({ event }) => event.type === 'agent/inbox/spliced' ? event.data.inserted : []) .find(message => message.source.kind === 'browser-context') // DSH 0.2 producer-owned kinds use `browser-context` (not plugin:). @@ -188,6 +190,7 @@ try { await stop() rpc = await start(true) assert.equal((await observation()).source, 'prepared') + assert.deepEqual((await rpc('session.models', { sessionId })).current, selectedModel) assert.ok((await rpc('session.list', {})).items.some(item => item.sessionId === sessionId)) const reopened = (await rpc('session.history', { sessionId })).events assert.deepEqual(reopened.slice(0, history.events.length), history.events) @@ -204,7 +207,7 @@ try { // append seed/permission metadata. The migrated history prefix stays exact. assert.deepEqual(reopenedLegacy.events.slice(0, migrated.events.length), migrated.events) assert.deepEqual(await readFile(legacyFile), legacyBytes) - console.log('Real DSH smoke passed: discovery, token authentication, create/list/history, V2 migration, V4 browser snapshots, and prepared projections after restart') + console.log('Real DSH smoke passed: discovery, token authentication, create/list/history, Session model selection, V2 migration, V4 browser snapshots, and prepared projections after restart') succeeded = true } catch (error) { console.error(hostLog)