Merge origin/main into feat/file-trace-tab: resolve external-plugin-guide version line (keep 8-tab count, adopt v0.18.0-alpha.0 alpha-track wording)
This commit is contained in:
+28
-48
@@ -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
@@ -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', {}),
|
||||
|
||||
@@ -62,18 +62,11 @@ type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
|
||||
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
|
||||
* external and the loader resolves them here). A superset is safe: the
|
||||
* require only answers what the chunk actually asks for. The shell's static
|
||||
* module table seeds React, Cordis, and the UI libraries (primitives/slots);
|
||||
* `dsh-client-runtime/client` normalizes onto the runtime package row
|
||||
* (stripClientSuffix). dsh-client-web-react / dsh-client-schema-form were
|
||||
* dropped in DSH 0.1.0-rc.8 (no rc.8 publish, nothing requires them) — the
|
||||
* chunks never asked for them, so they no longer belong here.
|
||||
*
|
||||
* DSH 0.1.2-alpha.1 removed the `dsh-client-runtime` package outright (the
|
||||
* seed table gained bare-name `@deepseek-ai/dsh-client-store` instead); the
|
||||
* runtime/client row below stays for 0.1.1-rc.x hosts — no chunk requires
|
||||
* it, and {@link buildExternalsRequire} keeps an unresolvable spec
|
||||
* undefined until a chunk actually asks (only then is it a loud error), so
|
||||
* the entry is inert on 0.1.2-alpha.1+.
|
||||
* module table seeds React, Cordis, and the UI libraries (primitives/slots).
|
||||
* `@deepseek-ai/dsh-client-runtime` was removed upstream in DSH 0.1.2-alpha
|
||||
* (its seed row became bare-name `@deepseek-ai/dsh-client-store`) and no
|
||||
* chunk ever required it, so its row is gone; so are dsh-client-web-react /
|
||||
* dsh-client-schema-form, dropped back in DSH 0.1.0-rc.8.
|
||||
*/
|
||||
export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'react',
|
||||
@@ -83,7 +76,6 @@ export const CHUNK_EXTERNALS: readonly string[] = [
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
]
|
||||
|
||||
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,52 +1,31 @@
|
||||
/**
|
||||
* Copy-button / chrome labels for DSH's shared `MarkdownText`, shaped for
|
||||
* BOTH prop generations the plugin supports:
|
||||
*
|
||||
* - 0.1.1-rc.x: optional flat prop `codeLabels` — the renderer reads
|
||||
* `labels.copyLabel` / `labels.copiedLabel` directly.
|
||||
* - 0.1.2-alpha.1+: renamed to a REQUIRED `labels` prop with a nested shape
|
||||
* (`labels.code.copyLabel` + a screen-reader-only `labels.footnotes`
|
||||
* heading). Passing only the old prop crashes the fence render with
|
||||
* "Cannot read properties of undefined (reading 'code')" — the exact
|
||||
* regression the mount lane caught on a real 0.1.2-alpha.1 host.
|
||||
*
|
||||
* The union object satisfies both readers, and {@link markdownTextProps}
|
||||
* passes it under BOTH prop names — each host ignores the one it does not
|
||||
* know. `footnotes` is left empty (the heading is sr-only; give it a real
|
||||
* string only if a locale key ever earns its place in all 19 dictionaries).
|
||||
* Copy-button / chrome labels for DSH's shared `MarkdownText` (DSH
|
||||
* 0.1.2-alpha contract): the renderer takes a REQUIRED nested `labels` prop
|
||||
* — `labels.code.copyLabel` / `labels.code.copiedLabel` for the fence copy
|
||||
* buttons plus a screen-reader-only `labels.footnotes` heading — and the
|
||||
* MarkdownText/CodeBlock are cordis-free, falling back to HARDCODED Chinese
|
||||
* when the labels are omitted, so every render site threads the plugin
|
||||
* dictionary's localized pair through here (re-evaluated per render).
|
||||
* `footnotes` is left empty (the heading is sr-only; give it a real string
|
||||
* only if a locale key ever earns its place in all 19 dictionaries).
|
||||
*/
|
||||
import type { ComponentProps } from 'react'
|
||||
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/** The flat copy-button pair the plugin threads through its own props
|
||||
* (e.g. MermaidMarkdownProps.codeLabels — the chunk contract stays put). */
|
||||
/** The flat copy-button pair the plugin threads through its own props (e.g.
|
||||
* MermaidMarkdownProps.codeLabels — the chunk contract stays put). */
|
||||
export interface MarkdownCopyLabels {
|
||||
copyLabel: string
|
||||
copiedLabel: string
|
||||
}
|
||||
|
||||
/** The dual-generation chrome labels object (see module doc). */
|
||||
export interface MarkdownChromeLabels extends MarkdownCopyLabels {
|
||||
/** 0.1.2-alpha.1+ nested reads. */
|
||||
code: MarkdownCopyLabels
|
||||
/** 0.1.2-alpha.1+ sr-only footnotes heading. */
|
||||
footnotes: string
|
||||
}
|
||||
|
||||
/** Build the dual-shape chrome labels from a flat copy-button pair. */
|
||||
export function markdownChromeLabels(labels: MarkdownCopyLabels): MarkdownChromeLabels {
|
||||
/** MarkdownText props carrying the nested chrome labels. */
|
||||
export function markdownTextProps(text: string, labels: MarkdownCopyLabels): ComponentProps<typeof MarkdownText> {
|
||||
return {
|
||||
copyLabel: labels.copyLabel,
|
||||
copiedLabel: labels.copiedLabel,
|
||||
code: { copyLabel: labels.copyLabel, copiedLabel: labels.copiedLabel },
|
||||
footnotes: '',
|
||||
text,
|
||||
labels: {
|
||||
code: { copyLabel: labels.copyLabel, copiedLabel: labels.copiedLabel },
|
||||
footnotes: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** MarkdownText props carrying the labels under BOTH prop names. The cast is
|
||||
* load-bearing: the plugin builds against the 0.1.1-rc.x declaration, where
|
||||
* `labels` does not exist yet (and vice versa on a 0.1.2-alpha.1+ host). */
|
||||
export function markdownTextProps(text: string, labels: MarkdownCopyLabels): ComponentProps<typeof MarkdownText> {
|
||||
const chrome = markdownChromeLabels(labels)
|
||||
return { text, codeLabels: chrome, labels: chrome } as unknown as ComponentProps<typeof MarkdownText>
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.17.1'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.18.0-alpha.0'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -304,39 +304,6 @@ export interface SidebarSessionPersistenceService {
|
||||
}>
|
||||
}
|
||||
|
||||
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
|
||||
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** Unary response mirror (`RpcResponse<T>` on the wire). */
|
||||
export interface SidebarRpcResponse<T> {
|
||||
rpcId: unknown
|
||||
result: SidebarRpcResult<T>
|
||||
}
|
||||
|
||||
/** The generic session-history RPC face the Side Chat transcript polls
|
||||
* (subagent.history verifies subagent-catalog membership, which our custom
|
||||
* side-thread children do not have — the generic session.history reads any
|
||||
* durable log directly). */
|
||||
export interface SidebarSessionHistoryRpc {
|
||||
history(
|
||||
payload: { sessionId: string; beforeSeq?: number; maxMessages?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
|
||||
}
|
||||
|
||||
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
|
||||
export interface SidebarConnectionHandle {
|
||||
api: {
|
||||
sessions: SidebarSessionHistoryRpc
|
||||
subagents: {
|
||||
history(
|
||||
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The client session list snapshot the sidebar subscribes to. */
|
||||
export interface SidebarSessionList {
|
||||
current: string | undefined
|
||||
@@ -527,8 +494,6 @@ export interface SidebarContextShape {
|
||||
webServer: SidebarWebServer
|
||||
/** The session store (host `.get`) and the client list feed (`.list`) faces. */
|
||||
sessions: SidebarSessionStore & SidebarSessionsService
|
||||
/** The wire handle the Side Chat transcript polls through. */
|
||||
connection: SidebarConnectionHandle
|
||||
/** The web runtime trust list (bind-derived). */
|
||||
webRuntime: SidebarWebRuntime
|
||||
/** The client slot registry (register/inject). */
|
||||
|
||||
+5
-2
@@ -39,7 +39,7 @@ import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
|
||||
import { registerBundleRoute } from './bundle-route.ts'
|
||||
import { launchExternal } from './open-external.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { SettingsConflictError } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import {
|
||||
@@ -715,7 +715,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
}
|
||||
}
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
|
||||
// DSH 0.1.2-alpha.2 validates namespaces at compile time
|
||||
// (SettingsNamespaceInput); the 'dsh-better-sidebar' literal passes, so the
|
||||
// runtime helper this used to call (settingsNamespace) is gone upstream.
|
||||
const ns = SIDEBAR_PREFS_NS
|
||||
// The structural settings mirror types `schema` as unknown, so the
|
||||
// generic is not inferred here; the real service resolves it from the
|
||||
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
|
||||
|
||||
@@ -416,13 +416,18 @@ export interface SidechatThreadInfo {
|
||||
}
|
||||
|
||||
/** The events a thread produced itself: everything after the LAST
|
||||
* `session/end-seed` marker (the fork-seed boundary). */
|
||||
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
|
||||
const events = entries.map(entry => entry.event)
|
||||
* `session/end-seed` marker (the fork-seed boundary). A log with no marker
|
||||
* (a thread created before seeding existed) is returned whole. */
|
||||
export function threadOwnLogEvents(events: readonly SidechatLogEvent[]): SidechatLogEvent[] {
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
if (events[index]?.type === 'session/end-seed') return events.slice(index + 1)
|
||||
}
|
||||
return events
|
||||
return [...events]
|
||||
}
|
||||
|
||||
/** {@link threadOwnLogEvents} over history rows (the client cache shape). */
|
||||
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
|
||||
return threadOwnLogEvents(entries.map(entry => entry.event))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+58
-2
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
|
||||
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
|
||||
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose' /
|
||||
* 'sidechat.info' / 'sidechat.events').
|
||||
*
|
||||
* A side thread is a child session the plugin creates ITSELF with a custom
|
||||
* seed — the parent's full event log up to the click moment, honestly closed
|
||||
@@ -43,10 +44,11 @@ import {
|
||||
type SeedEvent,
|
||||
type SidechatLogEvent,
|
||||
type SidechatThreadInfo,
|
||||
threadOwnLogEvents,
|
||||
} from './sidechat-core.ts'
|
||||
import { requireString, SidebarError } from './wire.ts'
|
||||
|
||||
/** The five Side Chat routes of the sidebar API (wire method names). */
|
||||
/** The six Side Chat routes of the sidebar API (wire method names). */
|
||||
export interface SidechatRoutes {
|
||||
/** Create a side thread child seeded with the parent's log up to now.
|
||||
* `question` is optional: empty creates an EMPTY thread (Codex-style
|
||||
@@ -61,12 +63,22 @@ export interface SidechatRoutes {
|
||||
'sidechat.dispose'(payload: unknown): Promise<{ accepted: true }>
|
||||
/** Live state + agent identity for the thread header. */
|
||||
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
|
||||
/** The thread's OWN transcript events, seed-cut host-side (the inherited
|
||||
* parent log never crosses the wire); `afterSeq` narrows the response to
|
||||
* the delta beyond it (poll tail). */
|
||||
'sidechat.events'(payload: unknown): Promise<{ events: SidechatLogEvent[] }>
|
||||
}
|
||||
|
||||
/** Timeout guarding the create call (the registry detaches it before the
|
||||
* handle becomes visible, so the child is never cancelled by it). */
|
||||
const CREATE_TIMEOUT_MS = 15_000
|
||||
|
||||
/** Head cap of one `sidechat.events` response (the ceiling the old
|
||||
* client-side walk could load: 40 pages × 200 events). A pathological
|
||||
* thread beyond it renders its tail window — the same degradation the
|
||||
* capped walk had, never a failed poll. */
|
||||
const EVENTS_CAP = 8_000
|
||||
|
||||
/** Per-activation disposers of created thread agents (the dispose route
|
||||
* releases them; the session and its history always stay persisted). */
|
||||
const threadDisposers = new Map<string, () => Promise<void>>()
|
||||
@@ -151,6 +163,37 @@ function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
|
||||
return agents?.get(childId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The thread's event log (seed + its own events, already expanded): the live
|
||||
* agent's in-memory log while the thread is attached — the freshest read,
|
||||
* including events not yet flushed — else the persisted logical log. Both
|
||||
* DSH generations expose these seams with the same shape (the 0.1.2
|
||||
* persistence layer packs chunk rows on disk but expands them on inspect),
|
||||
* which is why the transcript reads here instead of the client's
|
||||
* session-history RPC: that face (`ctx.connection.api`) was removed in
|
||||
* 0.1.2-alpha.1's Remote-gateway migration.
|
||||
*/
|
||||
async function threadLogEvents(ctx: Context, childId: string): Promise<readonly SidechatLogEvent[]> {
|
||||
const agent = liveThreadAgent(ctx, childId)
|
||||
if (agent !== undefined) {
|
||||
return agent.session.events as unknown as readonly SidechatLogEvent[]
|
||||
}
|
||||
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
|
||||
if (persistence === undefined) {
|
||||
throw new SidebarError('sidechat-error', 'the session persistence service is unavailable', 503)
|
||||
}
|
||||
try {
|
||||
const inspected = await persistence.inspect(childId)
|
||||
return inspected.events as unknown as readonly SidechatLogEvent[]
|
||||
} catch (error: unknown) {
|
||||
throw new SidebarError(
|
||||
'not-found',
|
||||
`thread "${childId}" is not available: ${error instanceof Error ? error.message : String(error)}`,
|
||||
404,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the Side Chat routes (all optional services degrade to a wire
|
||||
* error the tab surfaces inline). The record keys are the FULL wire method
|
||||
* names the /sidebar/api dispatcher looks up (`api[method]`). */
|
||||
@@ -340,5 +383,18 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
|
||||
}
|
||||
return { live: false }
|
||||
},
|
||||
|
||||
'sidechat.events': async (payload: unknown) => {
|
||||
const childId = requireString(payload, 'childId')
|
||||
const rawAfter = (payload as { afterSeq?: unknown }).afterSeq
|
||||
if (rawAfter !== undefined
|
||||
&& (typeof rawAfter !== 'number' || !Number.isSafeInteger(rawAfter) || rawAfter < 0)) {
|
||||
throw new SidebarError('bad-request', 'afterSeq must be a non-negative integer')
|
||||
}
|
||||
const events = await threadLogEvents(ctx, childId)
|
||||
const own = threadOwnLogEvents(events)
|
||||
const fresh = rawAfter === undefined ? own : own.filter(event => event.seq > rawAfter)
|
||||
return { events: fresh.length > EVENTS_CAP ? fresh.slice(fresh.length - EVENTS_CAP) : fresh }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user