diff --git a/src/client/SideChatView.module.css b/src/client/SideChatView.module.css
index d3178f8..040a9cd 100644
--- a/src/client/SideChatView.module.css
+++ b/src/client/SideChatView.module.css
@@ -2,10 +2,12 @@
* language mirrors the main conversation pane: right-aligned user bubbles
* on --dsw-specific-bubble, full-width assistant markdown, a capsule
* composer card on --dsw-specific-input-major with a circular accent
- * send/stop button, and shimmer reserved for the running status line and
- * executing tools. Motion is crossfade-only (≤200ms entrances, no
- * springs); reduced-motion disables every loop. All values consume DSW
- * tokens (fallbacks point at older alias tokens for rc-era hosts). */
+ * send/stop button. Tool calls, thinking and context injections share one
+ * collapsible-line chrome (no cards); shimmer is reserved for generating
+ * states (running status line, streaming rows, the creating hero). Motion
+ * is crossfade-only (≤200ms entrances, no springs); reduced-motion disables
+ * every loop. All values consume DSW tokens (fallbacks point at older alias
+ * tokens for rc-era hosts). */
.sidechat {
flex: 1;
min-height: 0;
@@ -172,82 +174,99 @@
overflow-wrap: anywhere;
}
-.sidechatReasoning {
+/* ── collapsible context rows (tool / thinking / injection, Codex-style) ──
+ * One quiet line — chevron + label + one-line summary — that expands into
+ * an indented body hung on a hairline thread. No cards, no fills: hierarchy
+ * rides ink weight alone. Streaming rows shimmer the label (shimmer =
+ * generating); failed rows go danger; reduced-motion stills every loop. */
+.sidechatRow {
align-self: stretch;
}
-.sidechatReasoningSummary {
- display: inline-block;
+.sidechatRowLine {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 1px 0;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
- cursor: pointer;
- list-style: none;
}
-.sidechatReasoningSummary::-webkit-details-marker {
+.sidechatRowSummary {
+ cursor: pointer;
+ list-style: none;
+ user-select: none;
+}
+
+.sidechatRowSummary::-webkit-details-marker {
display: none;
}
-.sidechatReasoningBody {
- margin-top: 2px;
- padding-left: 2px;
+.sidechatRowSummary:hover {
+ color: var(--dsw-alias-label-secondary);
+}
+
+.sidechatRowStatic {
+ cursor: default;
+}
+
+.sidechatRowChevron {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ transition: transform 100ms ease-out;
+}
+
+.sidechatRow[open] .sidechatRowChevron {
+ transform: rotate(90deg);
+}
+
+.sidechatRowLabel {
+ flex: none;
+ max-width: 60%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--dsw-alias-label-secondary);
+}
+
+.sidechatRowMono {
+ font-family: var(--dsw-font-mono);
+}
+
+.sidechatRowMeta {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--dsw-font-mono);
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.sidechatRowFailed .sidechatRowLabel,
+.sidechatRowFailed .sidechatRowMeta {
+ color: var(--dsw-alias-danger);
+}
+
+.sidechatRowBody {
+ margin: 2px 0 4px 7px;
+ padding: 2px 0 2px 10px;
+ border-left: 1px solid var(--dsw-alias-hairline);
+}
+
+.sidechatRowProse {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
overflow-wrap: anywhere;
+ max-height: 240px;
+ overflow-y: auto;
}
-.sidechatTool {
- align-self: stretch;
- border: 1px solid var(--dsw-alias-hairline);
- border-radius: 10px;
- background: var(--dsw-alias-bg-base);
- overflow: hidden;
-}
-
-.sidechatToolSummary {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 5px 10px;
- font: var(--dsw-font-xxs-12);
- color: var(--dsw-alias-label-secondary);
- cursor: pointer;
- list-style: none;
-}
-
-.sidechatToolSummary::-webkit-details-marker {
- display: none;
-}
-
-.sidechatToolName {
- font-family: var(--dsw-font-mono);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* Running tool: a light sweep across the summary row (shimmer = generating). */
-.sidechatToolExecuting {
- background-image: linear-gradient(
- 90deg,
- transparent 0%,
- var(--dsw-alias-interactive-bg-hover) 50%,
- transparent 100%
- );
- background-size: 200% 100%;
- animation: sidechatSweep 2.6s linear infinite;
-}
-
-.sidechatToolFailed {
- color: var(--dsw-alias-danger);
-}
-
-.sidechatToolArgs,
-.sidechatToolResult {
+.sidechatRowCode {
margin: 0;
- padding: 6px 10px;
- border-top: 1px solid var(--dsw-alias-hairline);
+ padding: 4px 0;
font: var(--dsw-font-xxs-12);
font-family: var(--dsw-font-mono);
color: var(--dsw-alias-label-secondary);
@@ -257,6 +276,25 @@
overflow-y: auto;
}
+.sidechatRowCode + .sidechatRowCode {
+ border-top: 1px solid var(--dsw-alias-hairline);
+}
+
+/* Shimmer sweep text: generating (streaming row labels, creating hero). */
+.sidechatShimmerText {
+ background-image: linear-gradient(
+ 90deg,
+ var(--dsw-alias-label-tertiary) 0%,
+ var(--dsw-alias-label-primary) 50%,
+ var(--dsw-alias-label-tertiary) 100%
+ );
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ animation: sidechatSweep 2.6s linear infinite;
+}
+
/* ── running status line (above the composer, main-pane style) ──────── */
.sidechatStatus {
flex: none;
@@ -388,9 +426,12 @@
animation: none;
}
.sidechatStatusText,
- .sidechatToolExecuting {
+ .sidechatShimmerText {
animation: none;
background-image: none;
color: var(--dsw-alias-label-tertiary);
}
+ .sidechatRowChevron {
+ transition: none;
+ }
}
diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx
index 446b366..5fe9f0f 100644
--- a/src/client/SideChatView.tsx
+++ b/src/client/SideChatView.tsx
@@ -24,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
+ IconChevronRightOutline14,
IconNewChatOutline16,
IconPlusOutline16,
IconSendOutline16,
@@ -43,7 +44,7 @@ import {
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
-import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
+import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
@@ -102,6 +103,7 @@ interface RowLabels {
copyLabel: string
copiedLabel: string
thinkLabel: string
+ injectionLabel: string
}
/** Merge history entries by event seq (newest wins), log order preserved. */
@@ -122,48 +124,112 @@ function threadDisplayTitle(title: string): string {
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
}
+/**
+ * One collapsible context row — the shared Codex-style chrome of tool
+ * calls, thinking and context injections: a single quiet line (chevron +
+ * label + one-line summary) that expands into an indented body hung on a
+ * hairline thread. Rows with nothing to reveal render as a static line.
+ */
+function CollapsibleRow(props: {
+ label: string
+ meta?: string
+ mono?: boolean
+ streaming?: boolean
+ failed?: boolean
+ children?: React.ReactNode
+}): React.ReactNode {
+ const label = (
+
+ {props.label}
+
+ )
+ const meta = props.meta !== undefined && props.meta !== ''
+ ? {props.meta}
+ : null
+ if (props.children === undefined) {
+ return (
+
+ {label}
+ {meta}
+
+ )
+ }
+ return (
+
+
+
+
+
+ {label}
+ {meta}
+
+ {props.children}
+
+ )
+}
+
/** One row renderer (React keys ride the source event seq). */
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
switch (row.kind) {
case 'user':
return (
-
+
)
case 'assistant':
return (
-
+
)
case 'reasoning':
return (
-
- {labels.thinkLabel}
- {row.text}
-
+
+ {row.text}
+
)
- case 'tool':
+ case 'injection':
return (
-
-
-
- {row.name}
-
- {row.args !== undefined && {row.args}}
- {row.resultText !== undefined && {row.resultText}}
-
+
+ {row.text}
+
)
+ case 'tool': {
+ const body = (
+ <>
+ {row.args !== undefined &&
{row.args}}
+ {row.resultText !== undefined &&
{row.resultText}}
+ >
+ )
+ return (
+
+ )
+ }
}
}
@@ -176,7 +242,12 @@ export function SideChatView(props: {
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo
(
- () => ({ copyLabel: t('copy'), copiedLabel: t('copied'), thinkLabel: t('sideChatThink') }),
+ () => ({
+ copyLabel: t('copy'),
+ copiedLabel: t('copied'),
+ thinkLabel: t('sideChatThink'),
+ injectionLabel: t('sideChatInjection'),
+ }),
[],
)
@@ -448,7 +519,12 @@ export function SideChatView(props: {
-
+
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
{t('sideChatEmptyDesc')}
diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx
index 5684db7..b613254 100644
--- a/src/client/Sidebar.tsx
+++ b/src/client/Sidebar.tsx
@@ -62,6 +62,16 @@ import css from './sidebar.module.css'
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
const FAILURE_LIMIT = 3
+/**
+ * Subagent auto-open debounce (ms). The host delivers a new child's origin
+ * and its title in SEPARATE frames: a Side Chat thread's first visible
+ * frame still shows a fallback title (no 'Side: ' prefix), so an immediate
+ * 0→N decision mistakes it for a genuine subagent and pops the task page.
+ * The trigger therefore re-evaluates against the live snapshot once the
+ * title frame has had time to land.
+ */
+const AUTO_OPEN_DEBOUNCE_MS = 500
+
/**
* OS file drags over the sidebar belong to the sidebar, not to the chat:
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
@@ -395,23 +405,45 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* Switching to a session that already has subagents never triggers — its
* baseline starts at the current count — so a deliberate layout is never
* fought.
+ *
+ * The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
+ * is also a subagent-origin child, and its 'Side: ' title lands one frame
+ * after its origin — an immediate check would misread that first frame as
+ * a new subagent and pop this page on every thread creation. The timer
+ * re-evaluates the ORIGINAL baseline against the live snapshot; by then
+ * the title filter (isSideThreadSummary) sees the settled label.
*/
const listBaselineRef = useRef
(undefined)
+ const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
useEffect(() => {
const prev = listBaselineRef.current
listBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
+ if (autoOpenPendingRef.current !== null) return
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
- if (!store.getPrefs().autoOpenSubagent) return
- if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
- store.reduce(s => s.panelOpen ? s : togglePanel(s))
- // Pin the landing to the right panel: the auto-opened Subagent page must
- // appear where the panel just expanded, not in a bottom-panel pane the
- // user last touched.
- store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
- ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
+ const baseline = prev
+ const timer = window.setTimeout(() => {
+ autoOpenPendingRef.current = null
+ if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
+ if (!store.getPrefs().autoOpenSubagent) return
+ if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
+ store.reduce(s => s.panelOpen ? s : togglePanel(s))
+ // Pin the landing to the right panel: the auto-opened Subagent page must
+ // appear where the panel just expanded, not in a bottom-panel pane the
+ // user last touched.
+ store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
+ ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
+ }, AUTO_OPEN_DEBOUNCE_MS)
+ autoOpenPendingRef.current = { baseline, timer }
}, [sessionList, sessionId, store, ctx])
+ // A session switch (or unmount) voids any armed auto-open recheck.
+ useEffect(() => () => {
+ const pending = autoOpenPendingRef.current
+ if (pending !== null) window.clearTimeout(pending.timer)
+ autoOpenPendingRef.current = null
+ }, [sessionId])
+
/**
* Job auto-activation: the moment a NEW background job appears for the
* current conversation (a job id the previous snapshot lacked), the
diff --git a/src/client/locales.ts b/src/client/locales.ts
index 8a9bbc7..fdffc7f 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -264,6 +264,7 @@ export const zh = {
sideChatComposerPlaceholder: '追问…',
sideChatThinking: '正在深入…',
sideChatThink: '思考过程',
+ sideChatInjection: '已注入上下文',
sideChatSend: '发送',
sideChatCancel: '停止',
sideChatCancelTitle: '中止当前回合(保留队列)',
@@ -570,6 +571,7 @@ export const en: Record = {
sideChatComposerPlaceholder: 'Ask a follow-up…',
sideChatThinking: 'Deep diving…',
sideChatThink: 'Thinking',
+ sideChatInjection: 'Context injected',
sideChatSend: 'Send',
sideChatCancel: 'Stop',
sideChatCancelTitle: 'Abort the running turn (queued work is kept)',
diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts
index dbef065..37ddbb1 100644
--- a/src/client/sidechat-transcript.ts
+++ b/src/client/sidechat-transcript.ts
@@ -5,8 +5,9 @@
*
* 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 drops the "Side conversation boundary"
- * prompt row, so the view shows only the thread's own conversation.
+ * `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.
*
* Live streaming: `assistant/message` events only land when a step
* completes, but `assistant/chunk` events stream token-level text and
@@ -14,7 +15,7 @@
* them with the assembled message once it lands (settled rows).
*/
import type { SidebarHistoryEntry } from '../context-types.ts'
-import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts'
+import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts'
/** One compact transcript row rendered in the thread view. `seq` is the
* source event's log sequence — stable row identity for React keys across
@@ -22,6 +23,10 @@ import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts'
* rows). */
export type SidechatTranscriptRow =
| { kind: 'user'; seq: number; text: string }
+ /** A context injection (the side boundary prompt + the parked in-progress
+ * snapshot, or any plugin-sourced context): rendered as one collapsible
+ * row, never as a user bubble. */
+ | { kind: 'injection'; seq: number; text: string }
/** `settled` distinguishes an assembled message from a still-streaming
* chunk accumulation (streaming rows are superseded by the settle). */
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
@@ -54,6 +59,39 @@ export function blockText(content: readonly unknown[]): string {
return text === '' ? '…' : text
}
+/** Cap for a tool row's one-line argument summary (display only). */
+const ARGS_SUMMARY_MAX = 80
+
+/** The most identifying argument keys, in priority order (bash's command,
+ * fs tools' paths, search's pattern, …). */
+const ARGS_SUMMARY_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt'] as const
+
+function flatTruncate(text: string): string {
+ const flat = text.replace(/\s+/g, ' ').trim()
+ return flat.length > ARGS_SUMMARY_MAX ? `${flat.slice(0, ARGS_SUMMARY_MAX - 1)}…` : flat
+}
+
+/**
+ * One-line summary of a tool call's raw arguments JSON for the collapsed
+ * row: the first identifying string field when the JSON parses, else the
+ * flattened raw text; empty when there is nothing worth showing.
+ */
+export function toolArgsSummary(args: string | undefined): string {
+ if (args === undefined) return ''
+ try {
+ const parsed = JSON.parse(args) as Record | null
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
+ for (const key of ARGS_SUMMARY_KEYS) {
+ const value = parsed[key]
+ if (typeof value === 'string' && value.trim() !== '') return flatTruncate(value)
+ }
+ }
+ } catch {
+ // Raw text fallthrough.
+ }
+ return flatTruncate(args)
+}
+
/** The plain text of a tool/result message (text blocks inside its
* `tool-result` content block). */
function resultTextOf(data: Record): string {
@@ -141,12 +179,12 @@ export async function collectOwnEvents(
/**
* Map a thread child's history rows onto compact transcript rows: the
- * inherited fork seed is cut at the last `session/end-seed`, the boundary
- * prompt row is dropped, `assistant/chunk` deltas accumulate into streaming
- * rows per (turn, step, block) and are superseded by the assembled
- * `assistant/message`, and tool invocations render one expandable line each
- * (arguments, paired result text, failure marker; a still-executing call is
- * marked until its result lands).
+ * inherited fork seed is cut at the last `session/end-seed`, context
+ * injections map onto a collapsible injection row, `assistant/chunk`
+ * deltas accumulate into streaming rows per (turn, step, block) and are
+ * superseded by the assembled `assistant/message`, and tool invocations
+ * render one expandable line each (arguments, paired result text, failure
+ * marker; a still-executing call is marked until its result lands).
* @param entries - history rows (event + host-computed view) in seq order.
* @returns display rows in log order.
*/
@@ -166,7 +204,26 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
switch (event.type) {
case 'user/message': {
const text = blockText(Array.isArray(data.content) ? data.content : [])
- if (text.startsWith(SIDE_BOUNDARY_PREFIX)) break
+ // Context injections (the boundary prompt + snapshot, plugin-sourced
+ // context) collapse into an injection row; genuine user messages —
+ // including the FIRST one, which the host now delivers as its own
+ // event — render as user rows.
+ if (isContextInjectionMessage(data)) {
+ const source = data.source as { kind?: unknown } | undefined
+ // Threads logged BEFORE the host split carry boundary(+snapshot)+
+ // question in ONE 'user' message. The boundary prompt is a known
+ // constant, so the message splits THERE: the injection row keeps
+ // the prompt, the remainder (snapshot + question if any — pure
+ // question in the common case) renders as the user's real message.
+ if (source?.kind === 'user' && text.startsWith(`${SIDE_BOUNDARY_PROMPT}\n\n`)) {
+ rows.push({ kind: 'injection', seq: event.seq, text: SIDE_BOUNDARY_PROMPT })
+ const body = text.slice(SIDE_BOUNDARY_PROMPT.length + 2)
+ if (body !== '') rows.push({ kind: 'user', seq: event.seq, text: body })
+ break
+ }
+ rows.push({ kind: 'injection', seq: event.seq, text })
+ break
+ }
rows.push({ kind: 'user', seq: event.seq, text })
break
}
diff --git a/src/sidechat-core.ts b/src/sidechat-core.ts
index e7fecf5..1171dd3 100644
--- a/src/sidechat-core.ts
+++ b/src/sidechat-core.ts
@@ -35,6 +35,11 @@ export const LABEL_MAX_CHARS = 48
* the two plugins' threads render consistently in either UI). */
export const SIDE_BOUNDARY_PREFIX = 'Side conversation boundary'
+/** The plugin identity stamped on the source of context-injection messages
+ * (boundary prompt + parked snapshot), so the transcript recognizes them
+ * structurally — not by text prefix. */
+export const SIDE_INJECTION_PLUGIN = 'dsh-better-sidebar'
+
/**
* The boundary prompt delivered as the thread's first user message: the
* inherited seed is reference context only, never active instruction.
@@ -365,18 +370,37 @@ export function sideLabel(question: string): string {
export function boundaryDelivered(events: readonly SidechatLogEvent[]): boolean {
for (const event of events) {
if (event.type !== 'user/message') continue
- const content = dataOf(event).content
- const first = Array.isArray(content) ? content[0] : content
- const text = typeof first === 'string'
- ? first
- : (typeof first === 'object' && first !== null && 'text' in first
- ? String((first as { text: unknown }).text)
- : '')
- if (text.startsWith(SIDE_BOUNDARY_PREFIX)) return true
+ if (messageLeadText(dataOf(event)).startsWith(SIDE_BOUNDARY_PREFIX)) return true
}
return false
}
+/** The leading text of a user/message's content (block array or bare string). */
+function messageLeadText(data: Record): string {
+ const content = data.content
+ const first = Array.isArray(content) ? content[0] : content
+ return typeof first === 'string'
+ ? first
+ : (typeof first === 'object' && first !== null && 'text' in first
+ ? String((first as { text: unknown }).text)
+ : '')
+}
+
+/**
+ * Whether a logged user/message is a CONTEXT INJECTION (the boundary prompt
+ * plus the parked in-progress snapshot) rather than a real user message.
+ * New threads deliver the injection via `agent.inject` stamped with a
+ * non-'user' source kind; threads created before that split carry
+ * boundary+question in ONE 'user' message, recognized by the boundary
+ * prefix. Both render as one collapsible injection row — never as a user
+ * bubble.
+ */
+export function isContextInjectionMessage(data: Record): boolean {
+ const source = data.source as { kind?: unknown } | null | undefined
+ if (source?.kind !== undefined && source.kind !== 'user') return true
+ return messageLeadText(data).startsWith(SIDE_BOUNDARY_PREFIX)
+}
+
/** The info the thread header shows (live runtime state + agent identity). */
export interface SidechatThreadInfo {
/** A live agent drives the thread right now (false = cold/persisted). */
diff --git a/src/sidechat-routes.ts b/src/sidechat-routes.ts
index 0535db4..c718de9 100644
--- a/src/sidechat-routes.ts
+++ b/src/sidechat-routes.ts
@@ -37,6 +37,7 @@ import {
buildSidechatInheritance,
resolvePresetId,
SIDE_BOUNDARY_PROMPT,
+ SIDE_INJECTION_PLUGIN,
SIDE_NEW_THREAD_TITLE,
sideLabel,
type SeedEvent,
@@ -124,6 +125,26 @@ function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
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
@@ -217,8 +238,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
} else {
const promptParts = [SIDE_BOUNDARY_PROMPT]
if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot)
- promptParts.push(question)
- admitFollowup(handle.agent, textPrompt(promptParts.join('\n\n')))
+ admitFirstContact(handle.agent, promptParts.join('\n\n'), question)
pinTitle(sideLabel(question))
}
return { childId }
@@ -257,8 +277,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
const snapshot = pendingSnapshots.get(childId)
pendingSnapshots.delete(childId)
if (snapshot !== undefined) parts.push(snapshot)
- parts.push(text)
- admitFollowup(agent, textPrompt(parts.join('\n\n')))
+ admitFirstContact(agent, parts.join('\n\n'), text)
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
if (titles !== undefined) {
try {
diff --git a/tests/sidechat-core.spec.ts b/tests/sidechat-core.spec.ts
index 5ea39c4..1516d2a 100644
--- a/tests/sidechat-core.spec.ts
+++ b/tests/sidechat-core.spec.ts
@@ -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)
+ })
+})
diff --git a/tests/sidechat-routes.spec.ts b/tests/sidechat-routes.spec.ts
index 6f18879..749ed97 100644
--- a/tests/sidechat-routes.spec.ts
+++ b/tests/sidechat-routes.spec.ts
@@ -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; provider?: string; model?: string } = {}) {
return {
id,
@@ -22,6 +22,7 @@ function agent(id: string, over: { events?: unknown[]; header?: Record {
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')
diff --git a/tests/sidechat-transcript.spec.ts b/tests/sidechat-transcript.spec.ts
index 5def836..06c8164 100644
--- a/tests/sidechat-transcript.spec.ts
+++ b/tests/sidechat-transcript.spec.ts
@@ -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":" "}')
+ })
+})
diff --git a/tests/subagent-detect.spec.ts b/tests/subagent-detect.spec.ts
index 2debf2c..2fe4e06 100644
--- a/tests/subagent-detect.spec.ts
+++ b/tests/subagent-detect.spec.ts
@@ -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' },