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/3] 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/3] 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/3] 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()