Files
apes-sidebar/src/sidechat-routes.ts
T
Atlas Zeng b7aab48deb feat(sidechat): Codex 风格转录重构 — 首问显示 / 注入拆分 / 折叠行 / 弹窗去抖 (#314)
* fix(sidechat): split context injection from the first user message

The first composer message rode the same user/message event as the side
boundary prompt (+ parked snapshot), and the transcript dropped that whole
row by its boundary prefix — swallowing the user's first question.

Deliver first contact as TWO messages: the boundary+snapshot rides
agent.inject (queued next-step context, claimed first at the opening step,
no wake) stamped with source kind 'plugin', and the question is the
follow-up that wakes the driver. The log records injection and question as
separate events; the transcript maps injections (plugin-sourced, or the
legacy boundary-prefix blob of pre-split threads) onto a collapsible
injection row and renders genuine user messages — the first one included —
as user rows. boundaryDelivered still recognizes the injection by its
unchanged boundary-prefix opening.

* feat(sidechat): codex-style collapsible rows and creating shimmer

Tool calls, thinking and context injections now share one quiet
single-line chrome (chevron + label + a one-line argument summary) that
expands into an indented body on a hairline thread — no cards or fills.
Streaming labels and the creating hero shimmer (shimmer = generating);
failed tools go danger; reduced-motion stills every loop.

* fix(sidebar): debounce subagent auto-open past the title frame

The host delivers a new child's origin and title in separate frames, so a
Side Chat thread's first visible frame lacks its 'Side: ' label and
tripped the 0→N auto-open — every thread creation popped the task page.
The trigger now rearms for 500ms and re-evaluates the original baseline
against the live snapshot, by which time the title filter recognizes the
thread. Genuine subagents still auto-open, half a second later.

* fix(sidechat): render legacy threads' first message as its own bubble

Threads created before the host split still log the boundary (+snapshot)
and the question as ONE 'user' message; the transcript mapped that row
wholly onto the injection row, so the user's first question only appeared
inside the expansion. The boundary prompt is a known constant, so the
legacy row now splits THERE: the injection row keeps the prompt, the
remainder renders as the user's real message (a pre-split snapshot, when
present, rides along — the common case is a pure question). Row React keys
become kind:seq so the split's two rows never collide.
2026-08-22 15:34:05 +08:00

345 lines
15 KiB
TypeScript

/**
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
*
* 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
* at an in-progress turn (see sidechat-core.ts). The child is marked
* `origin: 'subagent'` so the main session list hides it, and EVERY
* operation goes through these routes because the generic session RPCs are
* fenced away from subagent-origin identities (the api-remotes
* agent-lookup ownership fence). No DSH source is touched:
*
* - creation uses the public AgentRegistry.create seam (the same one
* api-proxy's session.fork and the subagent fork provider use), with the
* parent's preset composition and provider/model selection so the child's
* first request shares the parent's token prefix (provider-side prefix
* cache reuse);
* - the first prompt (boundary + question) and every follow-up are admitted
* with the stock `agent.followup`;
* - a cold thread (DSH restart, or a closed thread) is resumed with
* AgentRegistry.resume, composing the preset the child recorded.
*/
import { randomUUID } from 'node:crypto'
import { createUserMessage, type ContentBlock, type UserMessage } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { Context as CordisContext } from '@deepseek-ai/cordis'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {
Context,
SidebarAgentPresetsService,
SidebarSessionPersistenceService,
SidebarSessionTitleService,
} from './context-types.ts'
import {
boundaryDelivered,
buildSidechatInheritance,
resolvePresetId,
SIDE_BOUNDARY_PROMPT,
SIDE_INJECTION_PLUGIN,
SIDE_NEW_THREAD_TITLE,
sideLabel,
type SeedEvent,
type SidechatLogEvent,
type SidechatThreadInfo,
} from './sidechat-core.ts'
import { requireString, SidebarError } from './wire.ts'
/** The five 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
* immediate create); the first `sidechat.prompt` then carries the
* boundary + snapshot and earns the thread its real label. */
'sidechat.start'(payload: unknown): Promise<{ childId: string }>
/** Deliver one follow-up message to a thread (live, or cold-resumed). */
'sidechat.prompt'(payload: unknown): Promise<{ accepted: true }>
/** Abort the thread's running turn (queued work is preserved). */
'sidechat.cancel'(payload: unknown): Promise<{ accepted: true }>
/** Release the thread's live agent (session and history stay persisted). */
'sidechat.dispose'(payload: unknown): Promise<{ accepted: true }>
/** Live state + agent identity for the thread header. */
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
}
/** 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
/** 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>>()
/** The in-progress-turn snapshot captured at creation of an EMPTY thread,
* waiting to ride the first prompt (lost on a host restart — the boundary
* prompt is then delivered alone, a logged degradation). */
const pendingSnapshots = new Map<string, string>()
/** Resolve the parent's preset and build the child's composition setup
* (mirror of api-proxy's composeAgent minus the model-selection install —
* the child carries the parent's provider/model in agentOptions). */
async function composeChildSetup(
ctx: Context,
presetId: string | undefined,
): Promise<{ agentPreset?: string; setup: AgentSetup }> {
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
if (presets === undefined) {
return { setup: () => Promise.resolve() }
}
const resolved = await presets.resolve(presetId)
return {
agentPreset: resolved.id,
setup: async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) },
}
}
/** Build the cold-resume setup from the thread's PERSISTED record (the
* recorded preset wins, newest selection event first). */
async function composePersistedSetup(
ctx: Context,
childId: string,
): Promise<AgentSetup> {
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence === undefined) {
return () => Promise.resolve()
}
const inspected = await persistence.inspect(childId)
const presetId = resolvePresetId(inspected.meta, inspected.events)
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
if (presets === undefined || presetId === undefined) {
return () => Promise.resolve()
}
const resolved = await presets.resolve(presetId)
return async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) }
}
/** One text-block prompt (the thread boundary + question, or a follow-up). */
function textPrompt(text: string): ContentBlock[] {
return [{ type: 'text', text }]
}
/** Admit one user message to a live agent through the stock followup path. */
function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
const message: UserMessage = createUserMessage({ content: blocks, source: { kind: 'user' } })
agent.followup(message)
}
/**
* Deliver the thread's FIRST contact as TWO log-separated messages: the
* boundary prompt (+ the parked in-progress snapshot) rides `agent.inject`
* — queued model-facing context that does NOT wake the driver and is
* claimed FIRST at the opening step (Inbox.claim drains next-step before
* next-turn) — and the user's question is the follow-up that wakes it. The
* log therefore records two user/message events (injection, then question)
* instead of one wrapped blob: the transcript shows the question as a user
* bubble and collapses the injection as a context row. The injection source
* is stamped `kind: 'plugin'` so recognition is structural; its text still
* opens with SIDE_BOUNDARY_PREFIX, keeping boundaryDelivered intact.
*/
function admitFirstContact(agent: Agent, injectionText: string, question: string): void {
agent.inject(createUserMessage({
content: textPrompt(injectionText),
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
}))
admitFollowup(agent, textPrompt(question))
}
/** The live thread agent, or undefined (cold — the caller resumes). */
function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
const agents = ctx.get('agents') as { get(id: string): Agent | undefined } | undefined
return agents?.get(childId)
}
/** 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]`). */
export function buildSidechatApi(ctx: Context): SidechatRoutes {
return {
'sidechat.start': async (payload: unknown) => {
const sessionId = requireString(payload, 'sessionId')
const rawQuestion = (payload as { question?: unknown }).question
const question = typeof rawQuestion === 'string' ? rawQuestion.trim() : ''
const parent = liveThreadAgent(ctx, sessionId)
if (parent === undefined) {
throw new SidebarError('sidechat-error', `parent session "${sessionId}" is not running`, 409)
}
const parentSession = parent.session
const inheritance = buildSidechatInheritance(
parentSession.events as unknown as readonly SidechatLogEvent[],
)
const { agentPreset, setup } = await composeChildSetup(
ctx,
resolvePresetId(parentSession.header, parentSession.events),
)
const childId = `session-${randomUUID()}` as SessionId
const label = question === '' ? SIDE_NEW_THREAD_TITLE : sideLabel(question)
// Honest catalog citizenship: the durable descriptor keeps the thread
// a HEALTHY row in the host's subagents.list — a cold child without
// one is deterministically rendered as a 'corrupt' diagnostic. The
// SubagentView filters the 'Side: ' label out, so the topology UI
// stays noise-free; the row only serves enumeration correctness.
const descriptor = snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'sidechat',
label,
...(parent.options.provider === undefined ? {} : { agentProvider: parent.options.provider }),
...(parent.options.model === undefined ? {} : { agentModel: parent.options.model }),
})
const descriptorEvent: SeedEvent = {
type: 'subagent/descriptor',
seq: inheritance.seed.length,
time: Date.now(),
data: descriptor as unknown as Record<string, unknown>,
}
const seed = [...inheritance.seed, descriptorEvent]
const options: CreateAgentOptions = {
sessionId: childId,
meta: {
...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
parentSession: parentSession.id,
seedLength: seed.length,
origin: 'subagent',
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
...(agentPreset === undefined ? {} : { agentPreset }),
},
seed: seed as unknown as readonly SessionEvent[],
agentOptions: { ...parent.options },
setup,
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
}
const agents = ctx.get('agents') as { create(options: CreateAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
if (agents?.create === undefined) {
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
}
let handle: { agent: Agent; dispose(): Promise<void> }
try {
handle = await agents.create(options)
} catch (error) {
throw new SidebarError('sidechat-error', `thread creation failed: ${error instanceof Error ? error.message : String(error)}`, 500)
}
threadDisposers.set(childId, () => handle.dispose())
// Pin the thread label so the client can identify its threads by
// title prefix (the rename is a live-session op, no RPC fence).
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
const pinTitle = (label: string): void => {
if (titles === undefined) return
try {
titles.rename(handle.agent.session, label)
} catch {
// Keep the auto-generated title; the thread stays usable.
}
}
if (question === '') {
// Codex-style immediate create: no prompt yet — the composer owns
// the first message; the snapshot waits for it.
if (inheritance.snapshot !== null) pendingSnapshots.set(childId, inheritance.snapshot)
pinTitle(SIDE_NEW_THREAD_TITLE)
} else {
const promptParts = [SIDE_BOUNDARY_PROMPT]
if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot)
admitFirstContact(handle.agent, promptParts.join('\n\n'), question)
pinTitle(sideLabel(question))
}
return { childId }
},
'sidechat.prompt': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const text = requireString(payload, 'text').trim()
if (text === '') {
throw new SidebarError('bad-request', 'text is required')
}
let agent = liveThreadAgent(ctx, childId)
if (agent === undefined) {
// Cold thread: resume the persisted session under its recorded
// composition, then deliver the follow-up.
const agents = ctx.get('agents') as { resume(options: ResumeAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
if (agents?.resume === undefined) {
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
}
const setup = await composePersistedSetup(ctx, childId)
try {
const handle = await agents.resume({ resumeSessionId: childId as SessionId, setup })
threadDisposers.set(childId, () => handle.dispose())
agent = handle.agent
} catch (error) {
throw new SidebarError('sidechat-error', `thread resume failed: ${error instanceof Error ? error.message : String(error)}`, 500)
}
}
if (boundaryDelivered(agent.session.events as unknown as readonly SidechatLogEvent[])) {
admitFollowup(agent, textPrompt(text))
} else {
// First message of an immediately-created thread: it carries the
// boundary (+ the snapshot parked at creation, if still around)
// and earns the thread its real label.
const parts = [SIDE_BOUNDARY_PROMPT]
const snapshot = pendingSnapshots.get(childId)
pendingSnapshots.delete(childId)
if (snapshot !== undefined) parts.push(snapshot)
admitFirstContact(agent, parts.join('\n\n'), text)
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
if (titles !== undefined) {
try {
titles.rename(agent.session, sideLabel(text))
} catch {
// Keep the placeholder title; the thread stays usable.
}
}
}
return { accepted: true as const }
},
'sidechat.cancel': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
agent.cancel({ kind: 'user' }, { keepInbox: true })
}
return { accepted: true as const }
},
'sidechat.dispose': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
pendingSnapshots.delete(childId)
const dispose = threadDisposers.get(childId)
if (dispose !== undefined) {
threadDisposers.delete(childId)
try {
await dispose()
} catch {
// The agent may already be gone (restart); the session persists.
}
}
return { accepted: true as const }
},
'sidechat.info': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
const preset = agent.session.header.agentPreset
return {
live: true,
status: agent.status,
...(agent.options.provider === undefined ? {} : { provider: agent.options.provider }),
...(agent.options.model === undefined ? {} : { model: agent.options.model }),
...(preset === undefined ? {} : { preset }),
}
}
// Cold thread: only the persisted preset is worth reading back.
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence !== undefined) {
try {
const inspected = await persistence.inspect(childId)
const preset = resolvePresetId(inspected.meta, inspected.events)
return { live: false, ...(preset === undefined ? {} : { preset }) }
} catch {
// Unknown/gone session: report a bare cold info.
}
}
return { live: false }
},
}
}