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
+105 -64
View File
@@ -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;
}
}
+103 -27
View File
@@ -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 = (
<span
className={clsx(
css.sidechatRowLabel,
props.mono === true && css.sidechatRowMono,
props.streaming === true && css.sidechatShimmerText,
)}
>
{props.label}
</span>
)
const meta = props.meta !== undefined && props.meta !== ''
? <span className={css.sidechatRowMeta}>{props.meta}</span>
: null
if (props.children === undefined) {
return (
<div className={clsx(css.sidechatRowLine, css.sidechatRowStatic, props.failed === true && css.sidechatRowFailed)}>
{label}
{meta}
</div>
)
}
return (
<details className={css.sidechatRow}>
<summary
className={clsx(
css.sidechatRowLine,
css.sidechatRowSummary,
props.failed === true && css.sidechatRowFailed,
)}
>
<span className={css.sidechatRowChevron}>
<IconChevronRightOutline14 size={12} />
</span>
{label}
{meta}
</summary>
<div className={css.sidechatRowBody}>{props.children}</div>
</details>
)
}
/** One row renderer (React keys ride the source event seq). */
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
switch (row.kind) {
case 'user':
return (
<div key={row.seq} className={css.sidechatUser}>
<div key={`${row.kind}:${row.seq}`} className={css.sidechatUser}>
<MarkdownText text={row.text} codeLabels={labels} />
</div>
)
case 'assistant':
return (
<div key={row.seq} className={css.sidechatAssistant}>
<div key={`${row.kind}:${row.seq}`} className={css.sidechatAssistant}>
<MarkdownText text={row.text} codeLabels={labels} />
</div>
)
case 'reasoning':
return (
<details key={row.seq} className={css.sidechatReasoning}>
<summary className={css.sidechatReasoningSummary}>{labels.thinkLabel}</summary>
<div className={css.sidechatReasoningBody}>{row.text}</div>
</details>
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
label={labels.thinkLabel}
streaming={!row.settled}
>
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'tool':
case 'injection':
return (
<details key={row.seq} className={css.sidechatTool}>
<summary
className={clsx(
css.sidechatToolSummary,
row.executing === true && css.sidechatToolExecuting,
row.failed && css.sidechatToolFailed,
)}
>
<StateDot
state={row.executing === true ? 'ongoing' : row.failed ? 'error' : 'done'}
size={8}
/>
<span className={css.sidechatToolName}>{row.name}</span>
</summary>
{row.args !== undefined && <pre className={css.sidechatToolArgs}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatToolResult}>{row.resultText}</pre>}
</details>
<CollapsibleRow key={`${row.kind}:${row.seq}`} label={labels.injectionLabel}>
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'tool': {
const body = (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
)
return (
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
label={row.name}
meta={toolArgsSummary(row.args)}
mono
streaming={row.executing === true}
failed={row.failed}
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
/>
)
}
}
}
@@ -176,7 +242,12 @@ export function SideChatView(props: {
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo<RowLabels>(
() => ({ 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: {
<div className={css.sidechat}>
<div className={css.sidechatHero}>
<IconNewChatOutline16 />
<div className={css.sidechatHeroTitle}>
<div
className={clsx(
css.sidechatHeroTitle,
busy === 'starting' && css.sidechatShimmerText,
)}
>
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
</div>
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
+40 -8
View File
@@ -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<SidebarSessionList | undefined>(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
+2
View File
@@ -264,6 +264,7 @@ export const zh = {
sideChatComposerPlaceholder: '追问…',
sideChatThinking: '正在深入…',
sideChatThink: '思考过程',
sideChatInjection: '已注入上下文',
sideChatSend: '发送',
sideChatCancel: '停止',
sideChatCancelTitle: '中止当前回合(保留队列)',
@@ -570,6 +571,7 @@ export const en: Record<keyof typeof zh, string> = {
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)',
+67 -10
View File
@@ -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<string, unknown> | 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, unknown>): 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
}
+32 -8
View File
@@ -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, unknown>): 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<string, unknown>): 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). */
+23 -4
View File
@@ -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 {