Files
deepseek-harness/packages/api/activity-controller/src/client/model.ts
T
Yichen Jiang ddd8baa93a fix(activity): mark evicted heads on fresh observation and fit the popover to the viewport
Browser acceptance against a live scaffold surfaced two defects:

- A fresh observation anchors at the registry's earliest retained byte, so
  the client's gap predicate (anchor behind earliest) never fired for it: a
  viewer who watched eviction live saw the gap notice, while a reload of the
  same activity silently hid that the head was discarded. A fresh view (no
  accumulated text) anchored past offset zero now carries the gap mark;
  resumes keep their recorded state.

- The trigger-anchored popover (left: 0, width 440px) overflowed the right
  viewport edge on narrow windows — max-width alone cannot see the anchor
  offset. The open popover now measures its anchor and shifts left to keep
  the Menu primitive's 12px viewport margin, re-fitting on window resize and
  falling back to the pure CSS anchor when layout reports no width (jsdom).

JobListAction shares the latent overflow; tracked separately.
2026-08-24 23:07:17 +08:00

217 lines
8.0 KiB
TypeScript

/** Client-side activity state model shared by Remote transport and UI projection. */
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type { ActivityId } from '@deepseek-ai/dsh-activity/brand'
import type { ActivityObserveFrame, ActivityRow } from '../types.ts'
/** Bounded per-activity render tail, in UTF-16 code units. */
const RENDER_TAIL_LIMIT = 128 * 1024
/** Roster arrival lifecycle: `pending` until the first baseline lands. */
export type ActivityFeedPhase = 'pending' | 'ready'
/** One observed activity's live view state. */
export interface ObservedActivity {
readonly activityId: ActivityId
/** Accumulated output tail, bounded to the render limit. */
readonly text: string
/** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */
readonly gapBefore: boolean
/** Latest status delivered on the observation stream. */
readonly status: ActivityRow['status']
readonly detail?: string
/** True while the observation stream is open and the activity has not settled. */
readonly streaming: boolean
/** Terminal observation failure, when the stream ended abnormally. */
readonly error?: string
}
/** Immutable client activity state. */
export interface ActivityFeedSnapshot {
/** Roster rows per owner session; the empty key holds unowned activities. */
readonly rowsBySession: Readonly<Record<string, readonly ActivityRow[]>>
/** Live observation state keyed by activity id. */
readonly observed: Readonly<Record<string, ObservedActivity>>
readonly phase: ActivityFeedPhase
}
const UNOWNED = ''
/** Mutable observation bookkeeping behind one {@link ObservedActivity} view. */
interface ObservedState {
view: ObservedActivity
/** Resume offset for the next generation's `from`. */
cursor: number | undefined
}
/**
* Owns the client roster mirror and per-activity observation state. Pure data
* plus subscriptions — transport wiring stays in the client plugin, UI stays
* in slot components.
*/
export class ClientActivityModel {
private rowsBySession: Readonly<Record<string, readonly ActivityRow[]>> = {}
private readonly observedStates = new Map<string, ObservedState>()
private phase: ActivityFeedPhase = 'pending'
private readonly listeners = new Set<() => void>()
private snapshotCache: ActivityFeedSnapshot = { rowsBySession: {}, observed: {}, phase: 'pending' }
private snapshotDirty = false
/**
* Read the identity-stable current snapshot.
* @returns the cached snapshot, rebuilt only after a change.
*/
getSnapshot(): ActivityFeedSnapshot {
if (this.snapshotDirty) {
const observed: Record<string, ObservedActivity> = {}
for (const [id, state] of this.observedStates) observed[id] = state.view
this.snapshotCache = { rowsBySession: this.rowsBySession, observed, phase: this.phase }
this.snapshotDirty = false
}
return this.snapshotCache
}
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* Replace the whole roster from a generation baseline.
* @param rows - every visible row across all owner buckets.
*/
replaceBaseline(rows: readonly ActivityRow[]): void {
const next: Record<string, ActivityRow[]> = {}
for (const row of rows) {
const key = row.sessionId ?? UNOWNED
;(next[key] ??= []).push(row)
}
this.rowsBySession = next
this.phase = 'ready'
this.changed()
}
/**
* Replace one owner bucket wholesale; an empty replacement drops the key.
* @param sessionId - owner session, or undefined for the unowned bucket.
* @param rows - the bucket's complete visible rows.
*/
replaceBucket(sessionId: string | undefined, rows: readonly ActivityRow[]): void {
const key = sessionId ?? UNOWNED
if (rows.length === 0) {
this.rowsBySession = Object.fromEntries(
Object.entries(this.rowsBySession).filter(([bucket]) => bucket !== key))
} else {
this.rowsBySession = { ...this.rowsBySession, [key]: rows }
}
this.changed()
}
/**
* The resume offset for one activity's next observation generation.
* @param id - observed activity.
* @returns the last accepted `next`, or undefined for a fresh observation.
*/
cursorOf(id: ActivityId): number | undefined {
return this.observedStates.get(String(id))?.cursor
}
/**
* Install or reset observation state when a generation's anchor arrives.
* @param id - observed activity.
* @param frame - the generation's `opened` anchor.
*/
observeOpened(id: ActivityId, frame: Extract<ActivityObserveFrame, { type: 'opened' }>): void {
const existing = this.observedStates.get(String(id))
// A fresh view anchored past offset zero starts after an evicted head
// (fresh observations anchor at the registry's earliest retained byte), so
// it owes the same gap mark a live observer earned from lossy reads. A
// resume that already accumulated text keeps its recorded gap state.
const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0
const view: ObservedActivity = {
activityId: id,
text: existing?.view.text ?? '',
gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: true,
}
this.observedStates.set(String(id), { view, cursor: frame.from })
this.changed()
}
/**
* Append one output frame's chunks to the bounded render tail.
* @param id - observed activity.
* @param frame - a coalesced `output` frame.
*/
observeOutput(id: ActivityId, frame: Extract<ActivityObserveFrame, { type: 'output' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('')
let gapBefore = state.view.gapBefore || frame.lossy === true
|| frame.chunks.some(chunk => chunk.gapBefore === true)
if (text.length > RENDER_TAIL_LIMIT) {
let cut = text.length - RENDER_TAIL_LIMIT
// Never split a surrogate pair at the render bound.
const unit = text.charCodeAt(cut)
if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1
text = text.slice(cut)
gapBefore = true
}
state.view = { ...state.view, text, gapBefore }
state.cursor = frame.next
this.changed()
}
/**
* Record the terminal status delivered at the end of the observation stream.
* @param id - observed activity.
* @param frame - the terminal `status` frame.
*/
observeStatus(id: ActivityId, frame: Extract<ActivityObserveFrame, { type: 'status' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
state.view = {
...state.view,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: false,
}
this.changed()
}
/**
* Record a terminal observation failure.
* @param id - observed activity.
* @param error - the stream's terminal failure.
*/
observeFailed(id: ActivityId, error: unknown): void {
const state = this.observedStates.get(String(id))
if (state === undefined) return
state.view = { ...state.view, streaming: false, error: String(error) }
this.changed()
}
/**
* Drop observation state after the last observer stops.
* @param id - the no-longer-observed activity.
*/
observeStopped(id: ActivityId): void {
if (!this.observedStates.delete(String(id))) return
this.changed()
}
private changed(): void {
this.snapshotDirty = true
notifySubscribers(this.listeners, 'activity feed')
}
}