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' },