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:
1 parent
7dbd9b75e2
commit
b7aab48deb
11 files changed
+516
-140
No files matched your search
+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). */
|
||||
|
||||
Reference in new issue
Block a user