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.
This commit is contained in:
Atlas Zeng
2026-08-22 15:34:05 +08:00
committed by GitHub
parent 7dbd9b75e2
commit b7aab48deb
11 changed files with 516 additions and 140 deletions
+30
View File
@@ -12,12 +12,14 @@ import {
buildOpenTurnSnapshot,
buildSidechatInheritance,
hasDanglingToolCall,
isContextInjectionMessage,
resolvePresetId,
sideLabel,
sideThreadRows,
threadHasCompletedTurn,
threadTrailingPending,
SIDE_BOUNDARY_PROMPT,
SIDE_INJECTION_PLUGIN,
SIDE_LABEL_PREFIX,
type SeedEvent,
type SidechatLogEvent,
@@ -333,3 +335,31 @@ describe('boundaryDelivered', () => {
})])).toBe(true)
})
})
describe('isContextInjectionMessage', () => {
it('recognizes plugin-stamped sources structurally', () => {
expect(isContextInjectionMessage({
content: [{ type: 'text', text: 'runtime context' }],
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
})).toBe(true)
expect(isContextInjectionMessage({
content: [{ type: 'text', text: 'q' }],
source: { kind: 'user' },
})).toBe(false)
})
it('falls back to the boundary prefix for pre-split logs and sourceless rows', () => {
expect(isContextInjectionMessage({
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy question` }],
source: { kind: 'user' },
})).toBe(true)
expect(isContextInjectionMessage({
content: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy`,
})).toBe(true)
expect(isContextInjectionMessage({
content: [{ type: 'text', text: 'ordinary question' }],
source: { kind: 'user' },
})).toBe(false)
expect(isContextInjectionMessage({ content: [{ type: 'text', text: 'no source row' }] })).toBe(false)
})
})
+25 -11
View File
@@ -8,10 +8,10 @@
import { describe, expect, it, vi } from 'vitest'
import { buildSidechatApi } from '../src/sidechat-routes.ts'
import { SidebarError } from '../src/wire.ts'
import { SIDE_BOUNDARY_PROMPT, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
import { SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
import type { Context } from '../src/context-types.ts'
/** A fake live agent (followup/cancel spied). */
/** A fake live agent (inject/followup/cancel spied). */
function agent(id: string, over: { events?: unknown[]; header?: Record<string, unknown>; provider?: string; model?: string } = {}) {
return {
id,
@@ -22,6 +22,7 @@ function agent(id: string, over: { events?: unknown[]; header?: Record<string, u
header: { cwd: '/p', delegationDepth: 0, agentPreset: 'preset-a', ...over.header },
events: over.events ?? [],
},
inject: vi.fn(),
followup: vi.fn(),
cancel: vi.fn(),
}
@@ -138,12 +139,17 @@ describe('sidechat.start', () => {
agentProvider: 'test',
agentModel: 'model-x',
})
// First contact is SPLIT: the boundary rides agent.inject (plugin-stamped
// context, no wake), the question is the follow-up that wakes the driver.
expect(child.inject).toHaveBeenCalledTimes(1)
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string; plugin: string } }
expect(injection.source).toEqual({ kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN })
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(injection.content[0]!.text).not.toContain('explain the event flow')
expect(child.followup).toHaveBeenCalledTimes(1)
const message = child.followup.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string } }
expect(message.source).toEqual({ kind: 'user' })
const promptText = message.content[0]!.text
expect(promptText.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(promptText).toContain('explain the event flow')
expect(message.content[0]!.text).toBe('explain the event flow')
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
})
@@ -188,8 +194,11 @@ describe('sidechat.start', () => {
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string }> }
// Cut BEFORE the open turn: only the pending user message is inherited.
expect(options.seed.map(event => event.type)).toEqual(['user/message', 'subagent/descriptor'])
// The snapshot rides the injection, not the follow-up question.
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(injection.content[0]!.text).toContain('`bash` (executing)')
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(message.content[0]!.text).toContain('`bash` (executing)')
expect(message.content[0]!.text).toBe('what now?')
})
it('creates an EMPTY thread on a blank question (Codex-style immediate create)', async () => {
@@ -209,18 +218,22 @@ describe('sidechat.start', () => {
// No prompt yet: the composer owns the first message; the placeholder
// label is pinned and the in-progress snapshot is parked for it.
expect(child.inject).not.toHaveBeenCalled()
expect(child.followup).not.toHaveBeenCalled()
expect(services.rename).toHaveBeenCalledWith(child.session, SIDE_NEW_THREAD_TITLE)
// The first prompt carries the boundary + the parked snapshot and earns
// the thread its real label.
// The first prompt injects the boundary + the parked snapshot (split
// from the question) and earns the thread its real label.
services.agents.get = vi.fn((id: unknown) => (id === childId ? child : parent))
await api['sidechat.prompt']({ childId, text: 'explain the event flow' })
expect(child.inject).toHaveBeenCalledTimes(1)
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }>; source: { kind: string } }
expect(injection.source.kind).toBe('plugin')
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(injection.content[0]!.text).toContain('`bash` (executing)')
expect(child.followup).toHaveBeenCalledTimes(1)
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(message.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(message.content[0]!.text).toContain('`bash` (executing)')
expect(message.content[0]!.text).toContain('explain the event flow')
expect(message.content[0]!.text).toBe('explain the event flow')
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
})
@@ -256,6 +269,7 @@ describe('sidechat.prompt', () => {
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.prompt']({ childId: 'child', text: 'tell me more' })
expect(result).toEqual({ accepted: true })
expect(child.inject).not.toHaveBeenCalled()
expect(child.followup).toHaveBeenCalledTimes(1)
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(message.content[0]!.text).toBe('tell me more')
+62 -8
View File
@@ -1,13 +1,14 @@
/**
* Unit tests for the Side Chat transcript mapping (src/client/sidechat-
* transcript.ts): the seed cut at session/end-seed, the boundary-row drop,
* chunk streaming accumulation superseded by assembled messages, tool
* call/result pairing, and orphan failed results.
* transcript.ts): the seed cut at session/end-seed, context-injection rows
* (plugin-stamped sources and the legacy boundary-prefix blob), chunk
* streaming accumulation superseded by assembled messages, tool call/result
* pairing, and orphan failed results.
*/
import { describe, expect, it } from 'vitest'
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
import { SIDE_BOUNDARY_PREFIX } from '../src/sidechat-core.ts'
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
import { SIDE_BOUNDARY_PREFIX, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN } from '../src/sidechat-core.ts'
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
/** One history entry (event + optional view). */
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
@@ -29,15 +30,48 @@ function textBlocks(...texts: string[]): unknown[] {
}
describe('transcriptRows', () => {
it('cuts the inherited seed at the last end-seed and drops the boundary row', () => {
it('cuts the inherited seed at the last end-seed and renders the boundary as an injection row', () => {
const entries = [
entry(ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } })),
entry(ev('session/end-seed', 1)),
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'user' } })),
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN } })),
entry(ev('user/message', 3, { content: textBlocks('the side question'), source: { kind: 'user' } })),
]
const rows = transcriptRows(entries)
expect(rows).toEqual([{ kind: 'user', seq: 3, text: 'the side question' }])
expect(rows).toEqual([
{ kind: 'injection', seq: 2, text: `${SIDE_BOUNDARY_PREFIX}\n\nmode` },
{ kind: 'user', seq: 3, text: 'the side question' },
])
})
it('splits the LEGACY wrapped first message (boundary + question in one user row) at the boundary prompt', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, {
content: textBlocks(`${SIDE_BOUNDARY_PROMPT}\n\nthe first question`),
source: { kind: 'user' },
})),
entry(ev('user/message', 2, { content: textBlocks('follow-up'), source: { kind: 'user' } })),
]
const rows = transcriptRows(entries)
expect(rows).toEqual([
{ kind: 'injection', seq: 1, text: SIDE_BOUNDARY_PROMPT },
{ kind: 'user', seq: 1, text: 'the first question' },
{ kind: 'user', seq: 2, text: 'follow-up' },
])
})
it('renders any plugin-sourced context message as an injection row, boundary prefix or not', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('runtime context'), source: { kind: 'plugin', plugin: 'other-plugin' } })),
entry(ev('user/message', 2, { content: textBlocks('q'), source: { kind: 'user' } })),
]
const rows = transcriptRows(entries)
expect(rows).toEqual([
{ kind: 'injection', seq: 1, text: 'runtime context' },
{ kind: 'user', seq: 2, text: 'q' },
])
})
it('accumulates chunk deltas per block and supersedes them on settle', () => {
@@ -192,3 +226,23 @@ describe('collectOwnEvents', () => {
expect(result.entries.map(e => e.event.seq)).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
})
})
describe('toolArgsSummary', () => {
it('picks the most identifying string field', () => {
expect(toolArgsSummary('{"command":"ls -la","timeout":1000}')).toBe('ls -la')
expect(toolArgsSummary('{"file_path":"/a/b.ts","old_string":"x"}')).toBe('/a/b.ts')
expect(toolArgsSummary('{"pattern":"foo","path":"/repo"}')).toBe('/repo')
})
it('flattens and truncates raw text when no known key parses', () => {
expect(toolArgsSummary('{"custom":"v"}')).toBe('{"custom":"v"}')
expect(toolArgsSummary('not json at all')).toBe('not json at all')
expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toHaveLength(80)
expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toMatch(/…$/)
})
it('reads empty for missing or blank input', () => {
expect(toolArgsSummary(undefined)).toBe('')
expect(toolArgsSummary('{"command":" "}')).toBe('{"command":" "}')
})
})
+27
View File
@@ -98,6 +98,33 @@ describe('subagent detection over the sessions list feed', () => {
expect(detectNewDirectSubagent(before, after, 'p2')).toBe(false)
})
it('documents the title-frame race the Sidebar auto-open debounce absorbs', () => {
// The host delivers a new child's origin and title in SEPARATE frames:
// a Side Chat thread's FIRST visible frame still shows a fallback title
// (the cwd basename — no 'Side: ' prefix), so an immediate 0→N check
// misreads it as a genuine subagent. Sidebar.tsx therefore debounces
// AUTO_OPEN_DEBOUNCE_MS and re-evaluates the ORIGINAL baseline against
// the live snapshot; once the title frame has landed the same baseline
// yields no trigger. These two assertions pin exactly that dependency.
const baseline: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
const firstFrame: SidebarSessionList = {
current: 'p2',
byId: {
p2: { id: 'p2', displayTitle: 'P2' },
s2: { id: 's2', displayTitle: 'DSH-better-sidebar', origin: 'subagent', parentId: 'p2' },
},
}
expect(detectNewDirectSubagent(baseline, firstFrame, 'p2')).toBe(true) // the race
const settled: SidebarSessionList = {
current: 'p2',
byId: {
p2: { id: 'p2', displayTitle: 'P2' },
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
},
}
expect(detectNewDirectSubagent(baseline, settled, 'p2')).toBe(false) // after the debounce
})
it('resolves the main-agent root of the current session tree', () => {
const byId: SidebarSessionList['byId'] = {
main: { id: 'main', displayTitle: 'Main' },