Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions extensions/dsh-browser/src/content/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
63 changes: 5 additions & 58 deletions extensions/dsh-browser/src/panel/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -63,7 +63,6 @@ import {
restoreSubmittedDraft,
type ComposerDraft,
} from './composer.ts'
import { isNearScrollBottom } from './scroll.ts'
import {
latestSessionTitle,
projectedSessionTitle,
Expand Down Expand Up @@ -692,30 +691,8 @@ export function App(): React.JSX.Element {
const seqRef = useRef(0)
const sessionRef = useRef<string | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(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
Expand Down Expand Up @@ -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: [] }))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1501,7 +1462,6 @@ export function App(): React.JSX.Element {
nextQuestions: PendingQuestion[] = [],
preserveSelection = false,
): void {
stickConversationToBottom()
setRows([])
setStreamRow(null)
setDraft(emptyComposerDraft())
Expand Down Expand Up @@ -2233,8 +2193,7 @@ export function App(): React.JSX.Element {
)}
</section>
)}
<div className="messages-pane">
<div className="messages" ref={scrollRef} onScroll={syncScrollBottom}>
<div className="messages" ref={scrollRef}>
{rows.length === 0 && streamRow === null && !working && (
<div className="empty">
<span className="empty-logo"><img src={whaleUrl} alt="" /></span>
Expand Down Expand Up @@ -2270,18 +2229,6 @@ export function App(): React.JSX.Element {
</div>
)}
</div>
{!atBottom && (
<button
type="button"
className="scroll-to-bottom"
aria-label={copy.app.scrollToBottom}
title={copy.app.scrollToBottom}
onClick={() => scrollMessagesToBottom()}
>
<ChevronDownIcon />
</button>
)}
</div>
{question !== null && (
<QuestionCard
key={`${question.sessionId}:${question.rpcId}`}
Expand Down
49 changes: 33 additions & 16 deletions extensions/dsh-browser/tests/content-selection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MAX_SELECTION_CHARS } from '../src/selection.ts'
import { MAX_SELECTION_CHARS, type SelectionCapture } from '../src/selection.ts'
import { SelectionWatcher, readSelectionCapture } from '../src/content/selection.ts'

function selectText(value: string, anchor: Node | null = document.body): void {
Expand Down Expand Up @@ -28,6 +28,9 @@ afterEach(() => {
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()
})

Expand Down Expand Up @@ -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.
Expand All @@ -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')
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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'))
Expand All @@ -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)
Expand All @@ -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']) {
Expand All @@ -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')
Expand All @@ -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')
Expand All @@ -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))
Expand All @@ -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')
Expand All @@ -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()
Expand Down
35 changes: 11 additions & 24 deletions packages/browser/bridge-browser/src/remote-host-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ class RemoteHostApi implements BrowserHostApi {

async call(call: HostRpcCall): Promise<HostRpcResult> {
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)
Expand Down Expand Up @@ -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<HostRpcResult> {
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.
Expand Down Expand Up @@ -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,
}
Comment on lines +661 to +669

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Host default replaces session selection

session.models accepts a session ID, but this adapter discards it and maps the argument-less host-wide catalog's default model to the session-specific current field. When an existing session has selected another model, resuming or refreshing it therefore marks the deployment default as selected and misleads the user about which model the next prompt will use. The current model needs to come from session state, such as the model-selection projection, rather than modelCatalog.default.

case 'session.create':
case 'session.selectModel':
case 'session.attachment':
Expand Down Expand Up @@ -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<typeof modelSelectionOf>): 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)
Expand Down
Loading
Loading