fix(sidechat): read the transcript through the plugin's own events route

DSH 0.1.2-alpha.1 removed the client `ctx.connection.api` face (Remote
gateway migration), so every Side Chat transcript poll (`sessions.history`)
threw TypeError into the view's catch and was swallowed — the tab rendered
an empty transcript forever, silently (no console error, so the mount lane
never caught it). The successor APIs are not a drop-in either:
session/follow|page reject plain session addresses for origin:'subagent'
logs (agent-busy fence) and paging cannot read past the current cursor.

Move the transcript to the plugin's own wire — one transport for every
DSH generation:

- new `sidechat.events` route: live threads read the in-memory
  `agent.session.events`; cold threads read `sessionPersistence.inspect`
  (both seams exist since 0.1.0-rc.8; alpha.1's inspect returns the
  expanded logical log, never the packed chunk-row encoding). The
  inherited seed is cut host-side (`threadOwnLogEvents`), polls ride an
  `afterSeq` delta, and the response is tail-capped at 8000 events (the
  old client walk ceiling).
- SideChatView.fetchThread is single-path; collectOwnEvents (the client
  walk) is gone, and so are the ctx.connection mirror and inject entry
  (no consumer left).
- transcriptRows keeps re-cutting at session/end-seed as defense in
  depth.

Verification:
- unit: sidechat.events route (seed cut, afterSeq delta + never
  resurrecting the seed, cap, cold/live sources, 404/503/bad-request),
  full suite 1104 passed; typecheck and build (purity gate) green.
- mount e2e vs a real dsh-v0.1.2-alpha.1 source build: 14/14, including
  the Side Chat tab sweep polling sidechat.events (performance resource
  assertion) and live + post-dispose (persisted) route reads.
- mount e2e vs PATH dsh 0.1.0-rc.8: 14/14 (no regression).
This commit is contained in:
Menghuan1918
2026-08-30 22:25:45 +08:00
parent 3aab7ca3a5
commit 28447ca2e8
12 changed files with 241 additions and 220 deletions
+28 -48
View File
@@ -13,12 +13,15 @@
*
* Each side thread is a child session the plugin created itself with a
* custom seed (the parent's full log up to the click moment — see
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
* info go through the plugin's own /sidebar/api sidechat.* routes
* (subagent-origin identities are fenced from the generic session RPCs);
* the transcript is polled from the generic session.history RPC (seed-cut
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
* — see sidechat-transcript.ts.
* sidechat-core.ts). Transport: EVERY thread operation — creation,
* follow-up, cancel, dispose, info, and the transcript itself — goes
* through the plugin's own /sidebar/api sidechat.* routes (subagent-origin
* identities are fenced from the generic session RPCs, and DSH
* 0.1.2-alpha.1's Remote-gateway migration removed the client
* session-history face the transcript used to poll). The transcript route
* cuts the inherited seed host-side and answers afterSeq deltas; the
* mapping (boundary row dropped, chunk streaming accumulated) lives in
* sidechat-transcript.ts.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
@@ -36,7 +39,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
import type { Context, SidebarHistoryEntry, SidebarSessionEvent } from '../context-types.ts'
import {
SIDE_LABEL_PREFIX,
SIDE_NEW_THREAD_TITLE,
@@ -45,20 +48,13 @@ import {
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarTab } from './state.ts'
import css from './SideChatView.module.css'
/** Tail-page size for one transcript poll (events per page). Small on
* purpose: streaming polls ride the tail and merge by seq. */
const PAGE_MESSAGES = 8
/** First-attach walk page size: cold reads re-expand chunk-rows into one
* event per streamed delta, so a single answer can be hundreds of events —
* the walk must page big or earlier tool/call rows fall out of the window. */
const WALK_PAGE_EVENTS = 200
/** Poll cadence while the selected thread is running and the tab visible. */
const POLL_MS = 2000
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
@@ -92,10 +88,9 @@ export function consumeSidechatSeed(): string | undefined {
* StrictMode / HMR must not mint two threads for one tab). */
const inFlightStarts = new Set<string>()
/** Per-thread transcript cache: seed boundary + thread-own events merged by
* seq (streaming polls never re-download the inherited seed). */
/** Per-thread transcript cache: thread-own events merged by seq (polls ride
* the afterSeq delta and never re-download what they already hold). */
interface ThreadCache {
seedBoundary: number | null
entries: SidebarHistoryEntry[]
}
@@ -274,7 +269,7 @@ export function SideChatView(props: {
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
const cacheRef = useRef<ThreadCache>({ entries: [] })
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
@@ -327,43 +322,28 @@ export function SideChatView(props: {
}
}, [summary, tab.id, tab.title, ctx])
/** One transcript pull: the first read walks back to the seed boundary
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
* one tail page and merge (seq-deduped). */
/** One transcript pull: the thread's own events beyond the cached tail
* (first attach = the whole seed-cut slice; polls = afterSeq deltas),
* merged by seq. */
const fetchThread = useCallback(async (childId: string): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
const cache = cacheRef.current
try {
if (cache.seedBoundary === null) {
const walk = await collectOwnEvents(async (beforeSeq) => {
const response = await ctx.connection.api.sessions.history(
{
sessionId: childId,
maxMessages: WALK_PAGE_EVENTS,
...(beforeSeq === undefined ? {} : { beforeSeq }),
},
controller.signal,
)
if (!response.result.ok) throw new Error('history walk failed')
return response.result.value.events
})
cache.seedBoundary = walk.seedBoundary
cache.entries = mergeBySeq(cache.entries, walk.entries)
} else {
const response = await ctx.connection.api.sessions.history(
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
controller.signal,
)
if (!response.result.ok) return
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
const cache = cacheRef.current
const afterSeq = cache.entries.at(-1)?.event.seq
const { events } = await api.sidechatEvents(childId, afterSeq, controller.signal)
if (events.length > 0) {
// Wire events arrive as parsed JSON; the mirror narrows data to the
// record the mapping reads.
const incoming = events.map(event => ({ event: event as SidebarSessionEvent }))
cache.entries = mergeBySeq(cache.entries, incoming)
setRevision(value => value + 1)
}
setRevision(value => value + 1)
} catch {
// Aborted by a newer pull or a wire failure: keep the last rows.
}
}, [ctx])
}, [])
/** The thread header badge pull (live state + preset/model identity). */
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
@@ -377,7 +357,7 @@ export function SideChatView(props: {
// Reset the transcript cache whenever the binding changes, then focus
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { seedBoundary: null, entries: [] }
cacheRef.current = { entries: [] }
controllerRef.current?.abort()
setError(null)
setSaved(false)
+9 -1
View File
@@ -8,7 +8,7 @@
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
@@ -317,6 +317,14 @@ export const api = {
/** Live state + agent identity (provider/model/preset) of a thread. */
sidechatInfo: (childId: string) =>
call<SidechatThreadInfo>('sidechat.info', { childId }),
/** One transcript pull of a Side Chat thread: the thread's OWN events
* (the inherited seed is cut host-side and never crosses the wire).
* `afterSeq` narrows the response to the delta beyond it (poll tail). */
sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) =>
call<{ events: SidechatLogEvent[] }>('sidechat.events', {
childId,
...(afterSeq !== undefined ? { afterSeq } : {}),
}, signal),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
+1 -1
View File
@@ -35,7 +35,7 @@ import './layout.css'
* locale service backs the sidebar's copy — see locales.ts). `modules`
* (rc.8+) is the client module system the chunk loader resolves its
* externals through — Cordis guards service access without inject. */
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale', 'modules']
export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'modules']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
+10 -60
View File
@@ -1,13 +1,16 @@
/**
* Side Chat transcript mapping (browser half): turns a thread child's
* history rows (`session.history` — the generic RPC, which reads the durable
* log without activating the child) into compact display rows.
* Side Chat transcript mapping (browser half): turns a thread child's own
* events (`sidechat.events` — the plugin route, which reads the log without
* activating the child and cuts the inherited seed host-side) into compact
* display rows.
*
* A thread child's log starts with the ENTIRE inherited parent log as its
* fork seed. The mapping therefore cuts everything up to the LAST
* `session/end-seed` marker and maps context injections (the "Side
* conversation boundary" prompt, plugin-sourced context) onto a collapsible
* injection row, so the view shows only the thread's own conversation.
* fork seed. The route already cuts everything up to the LAST
* `session/end-seed` marker; the mapping re-applies the same cut so any
* seed event that somehow crosses the wire still never renders, and maps
* context injections (the "Side conversation boundary" prompt,
* plugin-sourced context) onto a collapsible injection row, so the view
* shows only the thread's own conversation.
*
* Live streaming: `assistant/message` events only land when a step
* completes, but `assistant/chunk` events stream token-level text and
@@ -124,59 +127,6 @@ function lastSeedEnd(events: readonly { type: string }[]): number {
return -1
}
/**
* Collect the thread's OWN events on first attach: walk backward from the
* log tail (oldest-first accumulation) until the `session/end-seed` marker
* surfaces, then keep everything after it.
*
* Page size matters: cold reads re-expand persisted chunk-rows into one
* `assistant/chunk` event per delta, so a single streamed answer can be
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
* earlier `tool/call` events fall out of the loaded window — the tool rows
* vanished on re-entry while the settled text survived. The walk therefore
* pages big; tail polls stay small.
*
* Exhaustion (log start reached without a marker — a thread created before
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
* caller stops re-walking and renders the window as-is.
*
* @param fetchPage - one history page (newest-first window ending at
* `beforeSeq`, exclusive; omit for the tail page).
* @param pageCap - safety bound on backward pages.
*/
export async function collectOwnEvents(
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
pageCap = 40,
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
const collected: SidebarHistoryEntry[] = []
let beforeSeq: number | undefined
for (let page = 0; page < pageCap; page++) {
const events = await fetchPage(beforeSeq)
if (events.length === 0) {
// Log start reached without a marker: the window IS the whole log.
return { seedBoundary: 0, entries: collected }
}
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
const fresh = olderThan === undefined
? [...events]
: events.filter(entry => entry.event.seq < olderThan)
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
if (seedEnd >= 0) {
collected.unshift(...fresh.slice(seedEnd + 1))
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
}
collected.unshift(...fresh)
if (fresh.length === 0) {
// The page overlaps entirely with what we have: nothing older exists.
return { seedBoundary: 0, entries: collected }
}
beforeSeq = fresh[0]!.event.seq
}
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
// rather than re-walking on every poll.
return { seedBoundary: 0, entries: collected }
}
/**
* Map a thread child's history rows onto compact transcript rows: the
* inherited fork seed is cut at the last `session/end-seed`, context