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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildSidechatApi } from '../src/sidechat-routes.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import { SIDE_BOUNDARY_PROMPT, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
|
||||
import { SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
/** A fake live agent (followup/cancel spied). */
|
||||
/** A fake live agent (inject/followup/cancel spied). */
|
||||
function agent(id: string, over: { events?: unknown[]; header?: Record<string, unknown>; provider?: string; model?: string } = {}) {
|
||||
return {
|
||||
id,
|
||||
@@ -22,6 +22,7 @@ function agent(id: string, over: { events?: unknown[]; header?: Record<string, u
|
||||
header: { cwd: '/p', delegationDepth: 0, agentPreset: 'preset-a', ...over.header },
|
||||
events: over.events ?? [],
|
||||
},
|
||||
inject: vi.fn(),
|
||||
followup: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}
|
||||
@@ -138,12 +139,17 @@ describe('sidechat.start', () => {
|
||||
agentProvider: 'test',
|
||||
agentModel: 'model-x',
|
||||
})
|
||||
// First contact is SPLIT: the boundary rides agent.inject (plugin-stamped
|
||||
// context, no wake), the question is the follow-up that wakes the driver.
|
||||
expect(child.inject).toHaveBeenCalledTimes(1)
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string; plugin: string } }
|
||||
expect(injection.source).toEqual({ kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN })
|
||||
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(injection.content[0]!.text).not.toContain('explain the event flow')
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string } }
|
||||
expect(message.source).toEqual({ kind: 'user' })
|
||||
const promptText = message.content[0]!.text
|
||||
expect(promptText.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(promptText).toContain('explain the event flow')
|
||||
expect(message.content[0]!.text).toBe('explain the event flow')
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
|
||||
})
|
||||
|
||||
@@ -188,8 +194,11 @@ describe('sidechat.start', () => {
|
||||
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string }> }
|
||||
// Cut BEFORE the open turn: only the pending user message is inherited.
|
||||
expect(options.seed.map(event => event.type)).toEqual(['user/message', 'subagent/descriptor'])
|
||||
// The snapshot rides the injection, not the follow-up question.
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(injection.content[0]!.text).toContain('`bash` (executing)')
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(message.content[0]!.text).toBe('what now?')
|
||||
})
|
||||
|
||||
it('creates an EMPTY thread on a blank question (Codex-style immediate create)', async () => {
|
||||
@@ -209,18 +218,22 @@ describe('sidechat.start', () => {
|
||||
|
||||
// No prompt yet: the composer owns the first message; the placeholder
|
||||
// label is pinned and the in-progress snapshot is parked for it.
|
||||
expect(child.inject).not.toHaveBeenCalled()
|
||||
expect(child.followup).not.toHaveBeenCalled()
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, SIDE_NEW_THREAD_TITLE)
|
||||
|
||||
// The first prompt carries the boundary + the parked snapshot and earns
|
||||
// the thread its real label.
|
||||
// The first prompt injects the boundary + the parked snapshot (split
|
||||
// from the question) and earns the thread its real label.
|
||||
services.agents.get = vi.fn((id: unknown) => (id === childId ? child : parent))
|
||||
await api['sidechat.prompt']({ childId, text: 'explain the event flow' })
|
||||
expect(child.inject).toHaveBeenCalledTimes(1)
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }>; source: { kind: string } }
|
||||
expect(injection.source.kind).toBe('plugin')
|
||||
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(injection.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(message.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(message.content[0]!.text).toContain('explain the event flow')
|
||||
expect(message.content[0]!.text).toBe('explain the event flow')
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
|
||||
})
|
||||
|
||||
@@ -256,6 +269,7 @@ describe('sidechat.prompt', () => {
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const result = await api['sidechat.prompt']({ childId: 'child', text: 'tell me more' })
|
||||
expect(result).toEqual({ accepted: true })
|
||||
expect(child.inject).not.toHaveBeenCalled()
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toBe('tell me more')
|
||||
|
||||
@@ -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":" "}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user