diff --git a/extensions/dsh-browser/src/content/selection.ts b/extensions/dsh-browser/src/content/selection.ts index ef8baaeb0..b2ec3b98b 100644 --- a/extensions/dsh-browser/src/content/selection.ts +++ b/extensions/dsh-browser/src/content/selection.ts @@ -28,6 +28,7 @@ const FIELD_SELECTOR = 'input, textarea, select, [contenteditable]' /** The deepest focused element, following shadow roots the page may use. */ function deepActiveElement(): Element | null { + if (typeof document === 'undefined') return null let active: Element | null = document.activeElement while (active?.shadowRoot?.activeElement != null) active = active.shadowRoot.activeElement return active @@ -106,6 +107,7 @@ function hasUserGesture(): boolean { * @returns the capture, or null when nothing quotable is selected. */ export function readSelectionCapture(): SelectionCapture | null { + if (typeof document === 'undefined') return null const raw = selectedText() if (raw === '') return null const { text, truncated } = normalizeSelectionText(raw) @@ -147,14 +149,18 @@ export class SelectionWatcher { if (next === this.enabled) return false this.enabled = next if (next) { - document.addEventListener('selectionchange', this.onSelectionChange) + if (typeof document !== 'undefined') { + document.addEventListener('selectionchange', this.onSelectionChange) + } // Opening the panel arms the watcher, and the text the user highlighted // just before opening it fires no further selectionchange. Reading it // needs no page gesture: the user acted on the extension to get here. this.flushNow(false) return true } - document.removeEventListener('selectionchange', this.onSelectionChange) + if (typeof document !== 'undefined') { + document.removeEventListener('selectionchange', this.onSelectionChange) + } this.cancel() this.lastEmitted = null return true @@ -170,7 +176,14 @@ export class SelectionWatcher { /** Release page listeners left behind by a replaced content script. */ dispose(): void { - this.setEnabled(false) + // Bypass the revision gate: a replaced script must drop its timer even + // when the last arm used a higher sequence than the default disarm. + this.enabled = false + if (typeof document !== 'undefined') { + document.removeEventListener('selectionchange', this.onSelectionChange) + } + this.cancel() + this.lastEmitted = null } private cancel(): void { diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index 6d3d93602..56d1cac20 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, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, 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' @@ -63,7 +63,6 @@ import { restoreSubmittedDraft, type ComposerDraft, } from './composer.ts' -import { isNearScrollBottom } from './scroll.ts' import { latestSessionTitle, projectedSessionTitle, @@ -692,30 +691,8 @@ 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((current) => current === next ? current : 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 @@ -824,7 +801,6 @@ export function App(): React.JSX.Element { followSnapshotsRef.current.clear() pendingHistoriesRef.current.clear() streamRefreshRef.current.clear() - stickConversationToBottom() setStreamRow(null) setRows([]) setDraft((current) => ({ ...current, images: [] })) @@ -886,25 +862,10 @@ export function App(): React.JSX.Element { if (sessionId !== undefined && queuedApproval !== undefined) void focusApprovalSession(queuedApproval) }, [queuedApproval?.id, queuedApproval?.sessionId, sessionChanging, state]) - // Stick to the newest row only while the user is already near the bottom. - useLayoutEffect(() => { - if (!atBottomRef.current) return - // 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 - // resize the scrollport without a scroll event; keep bottom state honest. + // Auto-scroll to the newest row. 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]) + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) + }, [rows, streamRow, working]) useEffect(() => { if (!showModelPicker) return @@ -1501,7 +1462,6 @@ export function App(): React.JSX.Element { nextQuestions: PendingQuestion[] = [], preserveSelection = false, ): void { - stickConversationToBottom() setRows([]) setStreamRow(null) setDraft(emptyComposerDraft()) @@ -2233,8 +2193,7 @@ export function App(): React.JSX.Element { )} )} -
-
+
{rows.length === 0 && streamRow === null && !working && (
@@ -2270,18 +2229,6 @@ export function App(): React.JSX.Element {
)}
- {!atBottom && ( - - )} -
{question !== null && ( { document.title = '' Reflect.deleteProperty(navigator, 'userActivation') vi.unstubAllGlobals() + // Drop pending settle timers before restoring real clocks. Otherwise + // useRealTimers() hands them to Node, and they fire after jsdom is gone. + vi.clearAllTimers() vi.useRealTimers() }) @@ -96,10 +99,25 @@ describe('reading a page selection', () => { }) describe('selection watcher', () => { + const watchers: SelectionWatcher[] = [] + + afterEach(() => { + for (const watcher of watchers.splice(0)) watcher.dispose() + }) + + function createWatcher( + emit: (capture: SelectionCapture) => void, + settleMs = 10, + ): SelectionWatcher { + const watcher = new SelectionWatcher(emit, settleMs) + watchers.push(watcher) + return watcher + } + it('ignores a selection the page moved without a user gesture', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) // An iframe calling getSelection().selectAllChildren() looks like this. @@ -119,7 +137,7 @@ describe('selection watcher', () => { it('reports the same passage again after the panel dropped it', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('quoted text') @@ -137,7 +155,7 @@ describe('selection watcher', () => { it('ignores an arm command older than the one already applied', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) expect(watcher.setEnabled(true, 5, 'worker-a')).toBe(true) // A slow DSH_CONTENT_READY reply computed before the panel opened. @@ -153,7 +171,7 @@ describe('selection watcher', () => { it('accepts a fresh worker even when its revision counter restarted', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) expect(watcher.setEnabled(true, 5, 'worker-a')).toBe(true) expect(watcher.setEnabled(false, 0, 'worker-b')).toBe(true) @@ -167,7 +185,7 @@ describe('selection watcher', () => { it('stays silent until a panel arms it', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) selectText('quoted text') document.dispatchEvent(new Event('selectionchange')) @@ -184,7 +202,7 @@ describe('selection watcher', () => { it('reports a highlight made before the panel opened', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) selectText('highlighted before opening') watcher.setEnabled(true) @@ -197,7 +215,7 @@ describe('selection watcher', () => { it('emits once for a drag that fires many selection changes', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) for (const partial of ['q', 'qu', 'quoted text']) { @@ -214,7 +232,7 @@ describe('selection watcher', () => { it('keeps the captured quote when the user clears the highlight', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('quoted text') @@ -230,7 +248,7 @@ describe('selection watcher', () => { it('reports the same passage after the highlight was cleared', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('quoted text') @@ -249,7 +267,7 @@ describe('selection watcher', () => { it('reports when the same captured prefix becomes truncated', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('x'.repeat(MAX_SELECTION_CHARS)) @@ -266,7 +284,7 @@ describe('selection watcher', () => { it('does not resend an unchanged highlight', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) + const watcher = createWatcher(emit) watcher.setEnabled(true) selectText('quoted text') @@ -281,12 +299,11 @@ describe('selection watcher', () => { it('releases the page listener when it is disarmed', () => { vi.useFakeTimers() const emit = vi.fn() - const watcher = new SelectionWatcher(emit, 10) - watcher.setEnabled(true) - watcher.dispose() - + const watcher = createWatcher(emit) + watcher.setEnabled(true, 5) selectText('quoted text') document.dispatchEvent(new Event('selectionchange')) + watcher.dispose() vi.advanceTimersByTime(50) expect(emit).not.toHaveBeenCalled() diff --git a/packages/browser/bridge-browser/src/remote-host-api.ts b/packages/browser/bridge-browser/src/remote-host-api.ts index e620d517e..e6da21996 100644 --- a/packages/browser/bridge-browser/src/remote-host-api.ts +++ b/packages/browser/bridge-browser/src/remote-host-api.ts @@ -126,7 +126,6 @@ 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) @@ -226,27 +225,6 @@ 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. @@ -680,6 +658,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': @@ -944,9 +931,9 @@ function sessionIdOf(payload: unknown): string | undefined { } /** Map Host ModelCatalog into the extension's session.models directory shape. */ -function adaptModelCatalog(value: unknown, next?: ReturnType): unknown { +function adaptModelCatalog(value: unknown): unknown { if (!isRecord(value)) return value - const selection = next ?? modelSelectionOf(value.default) + 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) diff --git a/packages/browser/bridge-browser/src/session-deferral.ts b/packages/browser/bridge-browser/src/session-deferral.ts index ba7eb673a..896586894 100644 --- a/packages/browser/bridge-browser/src/session-deferral.ts +++ b/packages/browser/bridge-browser/src/session-deferral.ts @@ -34,8 +34,6 @@ 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 } @@ -64,41 +62,13 @@ export function withSessionDeferral( const prune = (): void => { const cutoff = Date.now() - PROVISIONAL_TTL_MS for (const [id, entry] of provisional) { - if (entry.createdAt < cutoff && !entry.materialized && !materializing.has(id)) provisional.delete(id) + if (entry.createdAt < cutoff) 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') { @@ -112,8 +82,7 @@ export function withSessionDeferral( } if (call.method === 'session.history') { const sessionId = sessionIdOf(call.payload) - if (sessionId === undefined || !provisional.has(sessionId) - || provisional.get(sessionId)!.materialized) return api.call(call) + if (sessionId === undefined || !provisional.has(sessionId)) return api.call(call) return { ok: true, value: { @@ -154,7 +123,12 @@ export function withSessionDeferral( const entry = provisional.get(sessionId) if (entry === undefined) return api.call(call) const existing = materializing.get(sessionId) - const pending = existing ?? materialize(sessionId, entry, call.signal) + const pending = existing ?? api.call({ + rpcId: crypto.randomUUID(), + method: 'session.create', + payload: { ...entry.payload, sessionId }, + signal: call.signal, + }) if (existing === undefined) { materializing.set(sessionId, pending) void pending.then( @@ -164,6 +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), 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 471e1c82e..517b976b4 100644 --- a/packages/browser/bridge-browser/tests/remote-host-api.spec.ts +++ b/packages/browser/bridge-browser/tests/remote-host-api.spec.ts @@ -51,39 +51,6 @@ 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') @@ -179,7 +146,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', {}))).resolves.toEqual({ + await expect(api.call(call('session.models', { sessionId: 'session-1' }))).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 3ce92819c..5a535658c 100644 --- a/packages/browser/bridge-browser/tests/session-deferral.spec.ts +++ b/packages/browser/bridge-browser/tests/session-deferral.spec.ts @@ -213,64 +213,6 @@ 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()