feat(sidechat): 侧边对话——每对话一 Tab(Codex 对齐)+ 完整上下文继承 + 保存为新会话 (#286)

三轮实测驱动迭代:种子信封透传(surfaceOp 校验)、每线程一 Tab 交互重做完、四连修(fork 丢 this / 自绘 history+save 图标 / collectOwnEvents 大页回源修重开丢工具行 / descriptor 入种子修目录 corrupt 行 + 前缀过滤保拓扑零噪音)。739 单测 + 挂载冒烟 12/12 全绿。
This commit is contained in:
Atlas Zeng
2026-08-21 15:41:14 +08:00
committed by GitHub
parent 50a888845f
commit 7f4f84c4cf
27 changed files with 3501 additions and 13 deletions
+396
View File
@@ -0,0 +1,396 @@
/* Side Chat tab: ONE side conversation per tab (Codex-style). Visual
* 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). */
.sidechat {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* ── thread header (status dot · agent badge · threads menu · save) ─── */
.sidechatDetailHeader {
flex: none;
display: flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 4px 8px 4px 12px;
border-bottom: 1px solid var(--dsw-alias-hairline);
}
.sidechatHeaderDot {
flex: none;
}
.sidechatHeaderSpacer {
flex: 1;
min-width: 0;
}
.sidechatAgentBadge {
flex: none;
max-width: 55%;
padding: 1px 8px;
border: 1px solid var(--dsw-alias-hairline);
border-radius: 999px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidechatIconBtn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
transition: background-color 100ms ease-out, color 100ms ease-out;
}
.sidechatIconBtn:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.sidechatIconBtn:disabled {
opacity: 0.4;
cursor: default;
}
/* ── hero (unbound tab: the one brand moment) ───────────────────────── */
.sidechatHero {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 20px;
color: var(--dsw-alias-label-tertiary);
text-align: center;
animation: sidechatFadeIn 200ms ease-out;
}
.sidechatHeroTitle {
font: var(--dsw-font-s-14);
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.sidechatHeroDesc {
max-width: 300px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
line-height: 1.6;
}
.sidechatPrimaryBtn {
flex: none;
margin-top: 4px;
padding: 6px 14px;
border: none;
border-radius: 999px;
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
font: var(--dsw-font-s-13);
cursor: pointer;
transition: opacity 100ms ease-out;
}
.sidechatPrimaryBtn:hover:not(:disabled) {
opacity: 0.88;
}
.sidechatPrimaryBtn:disabled {
opacity: 0.4;
cursor: default;
}
.sidechatHint {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.sidechatError {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-danger);
}
/* ── transcript ─────────────────────────────────────────────────────── */
.sidechatScroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Rows crossfade in on append (keys are stable, so only NEW rows play). */
.sidechatScroll > * {
animation: sidechatRowIn 180ms ease-out;
}
/* User message: right-aligned bubble, same surface as the main pane. */
.sidechatUser {
align-self: flex-end;
max-width: 88%;
padding: 8px 14px;
border-radius: 18px;
background: var(--dsw-specific-bubble, var(--dsw-alias-bg-base));
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
}
/* Assistant message: full-width narration, no bubble — the main pane way. */
.sidechatAssistant {
align-self: stretch;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
}
.sidechatReasoning {
align-self: stretch;
}
.sidechatReasoningSummary {
display: inline-block;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
list-style: none;
}
.sidechatReasoningSummary::-webkit-details-marker {
display: none;
}
.sidechatReasoningBody {
margin-top: 2px;
padding-left: 2px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.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 {
margin: 0;
padding: 6px 10px;
border-top: 1px solid var(--dsw-alias-hairline);
font: var(--dsw-font-xxs-12);
font-family: var(--dsw-font-mono);
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 220px;
overflow-y: auto;
}
/* ── running status line (above the composer, main-pane style) ──────── */
.sidechatStatus {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 2px 14px 6px;
animation: sidechatFadeIn 160ms ease-out;
}
.sidechatStatusText {
font: var(--dsw-font-xxs-12);
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;
}
/* ── composer (capsule card, InputBar-aligned) ──────────────────────── */
.sidechatComposer {
flex: none;
display: flex;
flex-direction: column;
gap: 4px;
margin: 0 8px 8px;
padding: 8px 8px 6px 14px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-hairline));
border-radius: 16px;
background: var(--dsw-specific-input-major, var(--dsw-alias-bg-base));
box-shadow: var(--dsw-shadow-lv2, none);
}
.sidechatComposerInput {
width: 100%;
box-sizing: border-box;
padding: 2px 0;
border: none;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-s-14);
line-height: 22px;
outline: none;
resize: none;
max-height: 132px;
}
.sidechatComposerInput::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.sidechatComposerBar {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
}
.sidechatComposerMeta {
flex: 1;
min-width: 0;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidechatSendBtn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
cursor: pointer;
/* send ⇄ stop swap: keyed remount plays this, hover only fades. */
animation: sidechatBtnIn 120ms ease-out;
transition: opacity 100ms ease-out;
}
.sidechatSendBtn:hover:not(:disabled) {
opacity: 0.88;
}
.sidechatSendBtn:disabled {
opacity: 0.35;
cursor: default;
}
/* ── motion vocabulary (crossfade only, ≤200ms; loops reduced-motion off) */
@keyframes sidechatRowIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sidechatFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes sidechatBtnIn {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
@keyframes sidechatSweep {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.sidechatScroll > *,
.sidechatHero,
.sidechatStatus,
.sidechatSendBtn {
animation: none;
}
.sidechatStatusText,
.sidechatToolExecuting {
animation: none;
background-image: none;
color: var(--dsw-alias-label-tertiary);
}
}
+568
View File
@@ -0,0 +1,568 @@
/**
* Side Chat page: Codex-style side conversations for the current session.
*
* EVERY side conversation is its own sidebar tab (侧边对话1/2/3 …): the
* descriptor's createTab mints a fresh tab flagged `autoCreate` and this
* view creates the EMPTY thread on mount (one click = one conversation,
* exactly like the Codex app); the composer owns the first message (the
* host wraps it with the side boundary + the in-progress snapshot parked
* at creation, and the thread earns its real label — and the tab its
* title — from that first message). Closing the tab releases the thread's
* live agent (its history stays persisted); the header menu reopens any
* existing thread into a tab (deduped by threadId).
*
* Each side thread is a child session the plugin created itself with a
* custom seed (the parent's full log up to the click moment — see
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
* info go through the plugin's own /sidebar/api sidechat.* routes
* (subagent-origin identities are fenced from the generic session RPCs);
* the transcript is polled from the generic session.history RPC (seed-cut
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
* — see sidechat-transcript.ts.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
IconNewChatOutline16,
IconPlusOutline16,
IconSendOutline16,
IconStopFill16,
MarkdownText,
Menu,
StateDot,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
import {
SIDE_LABEL_PREFIX,
SIDE_NEW_THREAD_TITLE,
sideThreadRows,
threadHasCompletedTurn,
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarTab } from './state.ts'
import css from './SideChatView.module.css'
/** Tail-page size for one transcript poll (events per page). Small on
* purpose: streaming polls ride the tail and merge by seq. */
const PAGE_MESSAGES = 8
/** First-attach walk page size: cold reads re-expand chunk-rows into one
* event per streamed delta, so a single answer can be hundreds of events —
* the walk must page big or earlier tool/call rows fall out of the window. */
const WALK_PAGE_EVENTS = 200
/** Poll cadence while the selected thread is running and the tab visible. */
const POLL_MS = 2000
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
const COMPOSER_MAX_HEIGHT = 132
/** The thread a tab is bound to (durable in tab.meta across refreshes). */
export function sidechatThreadIdOf(tab: SidebarTab): string | undefined {
const meta = tab.meta as { threadId?: unknown } | undefined
return typeof meta?.threadId === 'string' ? meta.threadId : undefined
}
/** The parked reopen target consumed by the descriptor's createTab (the
* service's createTab receives no seed, so a thread-switch parks the id
* here and openTab picks it up synchronously — exactly one consume per
* park). */
let parkedReopen: string | undefined
/** Park a thread id for the NEXT sidechat openTab to reattach. */
export function parkSidechatReopen(threadId: string): void {
parkedReopen = threadId
}
/** Consume the parked reopen target (undefined = mint a fresh thread tab). */
export function consumeSidechatSeed(): string | undefined {
const value = parkedReopen
parkedReopen = undefined
return value
}
/** In-flight thread creations keyed by tab id (double-mount guard: React
* StrictMode / HMR must not mint two threads for one tab). */
const inFlightStarts = new Set<string>()
/** Per-thread transcript cache: seed boundary + thread-own events merged by
* seq (streaming polls never re-download the inherited seed). */
interface ThreadCache {
seedBoundary: number | null
entries: SidebarHistoryEntry[]
}
/** Row-render labels (locale-dependent, memoized once per mount). */
interface RowLabels {
copyLabel: string
copiedLabel: string
thinkLabel: string
}
/** Merge history entries by event seq (newest wins), log order preserved. */
function mergeBySeq(
previous: readonly SidebarHistoryEntry[],
incoming: readonly SidebarHistoryEntry[],
): SidebarHistoryEntry[] {
const bySeq = new Map<number, SidebarHistoryEntry>()
for (const entry of previous) bySeq.set(entry.event.seq, entry)
for (const entry of incoming) bySeq.set(entry.event.seq, entry)
return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq)
}
/** The display title of a thread: the durable label minus the 'Side: '
* prefix, with the fresh-thread placeholder localized. */
function threadDisplayTitle(title: string): string {
if (title === SIDE_NEW_THREAD_TITLE) return t('sideChatUntitled')
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
}
/** 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}>
<MarkdownText text={row.text} codeLabels={labels} />
</div>
)
case 'assistant':
return (
<div key={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>
)
case 'tool':
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>
)
}
}
/** One side conversation tab (one thread per tab, Codex-style). */
export function SideChatView(props: {
ctx: Context
scope: SessionScope
tab: SidebarTab
visible: boolean
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo<RowLabels>(
() => ({ copyLabel: t('copy'), copiedLabel: t('copied'), thinkLabel: t('sideChatThink') }),
[],
)
// The session list feed: thread rows (the header menu) + running states.
const list = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
)
const threads = useMemo(
() => sideThreadRows(list.byId, scope.sessionId),
[list, scope.sessionId],
)
// The thread this tab is bound to rides tab.meta (refresh-restored).
const threadId = sidechatThreadIdOf(tab)
const autoCreate = (tab.meta as { autoCreate?: unknown } | undefined)?.autoCreate === true
const [composer, setComposer] = useState('')
const [busy, setBusy] = useState<'starting' | 'sending' | 'saving' | null>(null)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [revision, setRevision] = useState(0)
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
const summary = threadId === undefined ? undefined : list.byId[threadId]
const running = summary?.running === true
/** The agent-identity badge of the thread header (preset · model). */
const agentBadge = useMemo(() => {
if (info === null) return ''
return [info.preset, info.model ?? info.provider].filter(Boolean).join(' · ')
}, [info])
/** Create this tab's thread (immediate-create tabs and hero retries). */
const startThread = useCallback(async (): Promise<void> => {
if (inFlightStarts.has(tab.id)) return
inFlightStarts.add(tab.id)
setBusy('starting')
setError(null)
try {
const { childId } = await api.sidechatStart(scope.sessionId)
ctx.betterSidebar?.updateTab(tab.id, { meta: { threadId: childId } })
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
inFlightStarts.delete(tab.id)
setBusy(null)
}
}, [ctx, scope.sessionId, tab.id])
// Codex-style immediate create: an autoCreate tab spawns its thread as
// soon as it first renders.
useEffect(() => {
if (threadId !== undefined || !autoCreate || !visible) return
void startThread()
}, [threadId, autoCreate, visible, startThread])
// The tab title follows the thread's durable label (the first prompt
// renames the thread; the strip picks it up here).
useEffect(() => {
const display = summary?.displayTitle
if (display === undefined) return
const title = threadDisplayTitle(display)
if (title !== '' && title !== tab.title) {
try {
ctx.betterSidebar?.updateTab(tab.id, { title })
} catch {
// A stale title is cosmetic; the thread keeps working.
}
}
}, [summary, tab.id, tab.title, ctx])
/** One transcript pull: the first read walks back to the seed boundary
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
* one tail page and merge (seq-deduped). */
const fetchThread = useCallback(async (childId: string): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
const cache = cacheRef.current
try {
if (cache.seedBoundary === null) {
const walk = await collectOwnEvents(async (beforeSeq) => {
const response = await ctx.connection.api.sessions.history(
{
sessionId: childId,
maxMessages: WALK_PAGE_EVENTS,
...(beforeSeq === undefined ? {} : { beforeSeq }),
},
controller.signal,
)
if (!response.result.ok) throw new Error('history walk failed')
return response.result.value.events
})
cache.seedBoundary = walk.seedBoundary
cache.entries = mergeBySeq(cache.entries, walk.entries)
} else {
const response = await ctx.connection.api.sessions.history(
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
controller.signal,
)
if (!response.result.ok) return
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
}
setRevision(value => value + 1)
} catch {
// Aborted by a newer pull or a wire failure: keep the last rows.
}
}, [ctx])
/** The thread header badge pull (live state + preset/model identity). */
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
try {
setInfo(await api.sidechatInfo(childId))
} catch {
// The badge is decorative; a wire failure keeps the last value.
}
}, [])
// Reset the transcript cache whenever the binding changes, then focus
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { seedBoundary: null, entries: [] }
controllerRef.current?.abort()
setError(null)
setSaved(false)
setInfo(null)
if (threadId !== undefined) {
void fetchInfo(threadId)
window.setTimeout(() => composerRef.current?.focus(), 0)
}
}, [threadId, fetchInfo])
// Poll while the tab is visible and the thread runs.
useEffect(() => {
if (!visible || threadId === undefined) return
void fetchThread(threadId)
if (!running) return
const timer = window.setInterval(() => {
void fetchThread(threadId)
void fetchInfo(threadId)
}, POLL_MS)
return () => { window.clearInterval(timer) }
}, [visible, threadId, running, fetchThread, fetchInfo])
useEffect(() => () => { controllerRef.current?.abort() }, [])
const rows = useMemo(
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
// The cache is a ref; revision bumps on every successful pull.
// eslint-disable-next-line react-hooks/exhaustive-deps
[threadId, revision],
)
const canSave = threadId !== undefined && threadHasCompletedTurn(cacheRef.current.entries)
const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries)
const freshThread = threadId !== undefined && rows.length === 0
// Follow the stream: stick to the bottom while the log grows.
useEffect(() => {
const scroller = scrollRef.current
if (scroller === null) return
scroller.scrollTop = scroller.scrollHeight
}, [rows.length, threadId])
/** Open a NEW thread tab (createTab mints the autoCreate tab; its view
* creates the thread on mount). */
const openNewThread = (): void => {
setMenuOpen(false)
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
}
/** Switch to an existing thread: parked for createTab, deduped to the
* already-open tab when there is one. */
const openExistingThread = (id: string): void => {
setMenuOpen(false)
if (id === threadId) return
parkSidechatReopen(id)
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
}
const menuItems = useMemo<MenuEntry[]>(() => {
const items: MenuEntry[] = [
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
]
if (threads.length > 0) {
items.push({ type: 'separator', id: '$sep' })
for (const row of threads) {
items.push({
id: row.id,
label: threadDisplayTitle(row.title),
...(row.running ? { icon: <StateDot state="ongoing" size={8} /> } : {}),
})
}
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threads])
const growComposer = (): void => {
const field = composerRef.current
if (field === null) return
field.style.height = '0px'
field.style.height = `${Math.min(field.scrollHeight, COMPOSER_MAX_HEIGHT)}px`
}
const handleSend = async (): Promise<void> => {
const text = composer.trim()
if (text === '' || threadId === undefined || busy !== null) return
setBusy('sending')
setError(null)
try {
await api.sidechatPrompt(threadId, text)
setComposer('')
const field = composerRef.current
if (field !== null) field.style.height = ''
void fetchThread(threadId)
void fetchInfo(threadId)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
setBusy(null)
}
}
const handleCancel = async (): Promise<void> => {
if (threadId === undefined || busy !== null) return
try {
await api.sidechatCancel(threadId)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
}
}
const handleSave = async (): Promise<void> => {
if (threadId === undefined || !canSave || busy !== null) return
setBusy('saving')
setError(null)
setSaved(false)
try {
// NOTE: fork must stay a METHOD call — `ctx.sessions.fork` is the
// client-runtime sessions service, and an unbound reference loses
// `this` (its fork reads this.list for the title bump).
if (ctx.sessions.fork === undefined) throw new Error('session fork is unavailable')
const newId = await ctx.sessions.fork({ sessionId: threadId, increaseTitle: true })
const title = summary === undefined ? '' : threadDisplayTitle(summary.displayTitle).trim()
const binding = ctx.sessions.binding?.(newId)
if (binding !== undefined && title !== '') {
await binding.session.rename(title)
}
ctx.sessions.open?.(newId)
setSaved(true)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
setBusy(null)
}
}
// ── unbound tab: the hero (fresh autoCreate tabs flash a creating state
// until the thread lands; legacy persisted tabs offer a manual start) ──
if (threadId === undefined) {
return (
<div className={css.sidechat}>
<div className={css.sidechatHero}>
<IconNewChatOutline16 />
<div className={css.sidechatHeroTitle}>
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
</div>
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
{busy !== 'starting' && (
<button
type="button"
className={css.sidechatPrimaryBtn}
onClick={() => void startThread()}
>
{error === null ? t('sideChatNew') : t('sideChatRetry')}
</button>
)}
</div>
</div>
)
}
return (
<div className={css.sidechat}>
<div className={css.sidechatDetailHeader}>
{running && <StateDot state="ongoing" size={8} className={css.sidechatHeaderDot} />}
{agentBadge !== '' && <span className={css.sidechatAgentBadge}>{agentBadge}</span>}
<span className={css.sidechatHeaderSpacer} />
<Menu
open={menuOpen}
anchor={(
<button
type="button"
className={css.sidechatIconBtn}
onClick={() => { setMenuOpen(value => !value) }}
title={t('sideChatThreads')}
>
<IconHistoryOutline16 />
</button>
)}
items={menuItems}
selectedId={threadId}
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
onClose={() => { setMenuOpen(false) }}
align="end"
portal
dense
/>
<button
type="button"
className={css.sidechatIconBtn}
onClick={() => void handleSave()}
disabled={!canSave || busy !== null}
title={`${t('sideChatSave')} — ${t('sideChatSaveTitle')}`}
>
<IconSaveOutline16 />
</button>
</div>
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
{canSave && trailingPending
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
{saved && <div className={css.sidechatHint}>{t('sideChatSaved')}</div>}
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
<div ref={scrollRef} className={css.sidechatScroll}>
{rows.map(row => renderRow(row, rowLabels))}
</div>
{running && (
<div className={css.sidechatStatus}>
<StateDot state="ongoing" size={8} />
<span className={css.sidechatStatusText}>{t('sideChatThinking')}</span>
</div>
)}
<div className={css.sidechatComposer}>
<textarea
ref={composerRef}
className={css.sidechatComposerInput}
value={composer}
placeholder={freshThread ? t('sideChatFirstPlaceholder') : t('sideChatComposerPlaceholder')}
rows={1}
onChange={event => {
setComposer(event.target.value)
growComposer()
}}
onKeyDown={event => {
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
void handleSend()
}}
/>
<div className={css.sidechatComposerBar}>
<span className={css.sidechatComposerMeta}>
{running ? '' : agentBadge}
</span>
{running ? (
<button
key="stop"
type="button"
className={css.sidechatSendBtn}
onClick={() => void handleCancel()}
disabled={busy !== null}
title={t('sideChatCancelTitle')}
>
<IconStopFill16 />
</button>
) : (
<button
key="send"
type="button"
className={css.sidechatSendBtn}
onClick={() => void handleSend()}
disabled={composer.trim() === '' || busy !== null}
title={t('sideChatSend')}
>
<IconSendOutline16 />
</button>
)}
</div>
</div>
</div>
)
}
+16 -3
View File
@@ -38,9 +38,11 @@ import type {
import {
collectBranchIds,
countSubagentDescendants,
isSideThreadSummary,
rootAncestor,
} from './subagent-detect.ts'
import { lastActivity } from './subagent-activity.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
import {
collectTreeJobs,
formatJobDuration,
@@ -64,13 +66,16 @@ const JOB_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const JOB_KILL_ARM_MS = 3000
/** The direct subagent children of one parent (durable `origin` rows). */
/** The direct subagent children of one parent (durable `origin` rows;
* Side Chat threads ride the same origin but are tab-strip conversations,
* never topology). */
function directChildren(
byId: Readonly<Record<string, SidebarSessionSummary>>,
parentSessionId: string,
): SidebarSessionSummary[] {
return Object.values(byId).filter(
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId
&& !isSideThreadSummary(summary),
)
}
@@ -235,6 +240,14 @@ function CatalogRows({
openChild, refresh,
}: RowsProps) {
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
// Side Chat threads are honest catalog citizens (durable descriptor, 'Side: '
// label) but they are NOT subagent topology — filter them out here (the tab
// strip owns them). Legacy threads created before the descriptor fix still
// arrive as corrupt diagnostics; they are recognized by summary title.
const visibleEntries = (catalog?.entries ?? []).filter((entry) => {
if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false)
})
return (
<>
{emptyLoading && (
@@ -253,7 +266,7 @@ function CatalogRows({
</button>
</div>
)}
{(catalog?.entries ?? []).map((entry) => {
{visibleEntries.map((entry) => {
if (entry.kind === 'diagnostic') {
return (
<div key={entry.id} className={css.subagentNode}>
+18
View File
@@ -7,6 +7,7 @@
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { SidechatThreadInfo } from '../sidechat-core.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
@@ -244,6 +245,23 @@ export const api = {
id,
...(reason !== undefined ? { reason } : {}),
})),
/** Create a Side Chat thread: a child session seeded with the parent's
* full log up to now. Empty question = immediate create (Codex-style):
* the thread opens empty, the first prompt carries the boundary. */
sidechatStart: (sessionId: string, question?: string) =>
call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }),
/** Deliver one follow-up message to a Side Chat thread. */
sidechatPrompt: (childId: string, text: string) =>
call<{ accepted: true }>('sidechat.prompt', { childId, text }),
/** Abort a Side Chat thread's running turn (queued work is preserved). */
sidechatCancel: (childId: string) =>
call<{ accepted: true }>('sidechat.cancel', { childId }),
/** Release a Side Chat thread's live agent (history stays persisted). */
sidechatDispose: (childId: string) =>
call<{ accepted: true }>('sidechat.dispose', { childId }),
/** Live state + agent identity (provider/model/preset) of a thread. */
sidechatInfo: (childId: string) =>
call<SidechatThreadInfo>('sidechat.info', { childId }),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
+50 -3
View File
@@ -1,13 +1,13 @@
/**
* The 6 built-in tab descriptors: the plugin registers its own pages
* (editor / git / terminal / browser / subagent / diff) through
* The 7 built-in tab descriptors: the plugin registers its own pages
* (editor / git / subagent / sidechat / terminal / browser / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
@@ -17,6 +17,8 @@ import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
@@ -165,6 +167,51 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
/>
),
},
{
id: 'sidechat',
title: () => t('sideChat'),
icon: (size: number) => <IconNewChatOutline16 size={size} />,
order: 35,
// Codex-style: EVERY side conversation is its own tab. A plain open
// mints a fresh tab flagged `autoCreate` (the view creates the EMPTY
// thread on mount); a thread switch from the header menu parks the
// target id for a deterministic `sidechat:<threadId>` reattach tab.
createTab: () => {
const threadId = consumeSidechatSeed()
if (threadId !== undefined) {
return {
tab: {
id: `sidechat:${threadId}`,
type: 'sidechat',
title: t('sideChat'),
meta: { threadId },
},
}
}
return {
tab: {
id: `sidechat:new-${crypto.randomUUID()}`,
type: 'sidechat',
title: t('sideChatUntitled'),
meta: { autoCreate: true },
},
}
},
// One tab per thread: an already-open thread focuses instead of
// duplicating; unbound fresh tabs never dedupe (each mints its own).
dedupeKey: (tab) => sidechatThreadIdOf(tab),
// Closing the tab releases the thread's live agent; the session and
// its history stay persisted (reopen from any thread's header menu).
onClose: (tab) => {
const threadId = sidechatThreadIdOf(tab)
if (threadId !== undefined) {
void api.sidechatDispose(threadId).catch(() => {})
}
},
component: ({ ctx, scope, tab, visible }) => (
<SideChatView ctx={ctx} scope={scope} tab={tab} visible={visible} />
),
},
{
id: 'terminal',
title: () => t('terminal'),
+20
View File
@@ -117,3 +117,23 @@ export const IconGlobeOutline16 = ({ size = 16, className }: IconProps) => (
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** History glyph (thread switcher): a clock with a counterclockwise arrow,
* in the app's outline style — the "past conversations" mark. */
export const IconHistoryOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.4 6.8A5.6 5.6 0 1 1 2.4 9.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M2.2 3.4v3.4h3.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 5.4V8l1.9 1.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** Save glyph (save-as-new-session): the classic floppy disk, in the app's
* outline style. */
export const IconSaveOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.2 14.5h7.6a1.2 1.2 0 0 0 1.2-1.2V4.9L10.6 2.5H4.2A1.2 1.2 0 0 0 3 3.7v9.6a1.2 1.2 0 0 0 1.2 1.2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M10 2.5v2.6H5.6V2.5" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M5.4 14.5v-4.2h5.2v4.2" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
)
+46
View File
@@ -247,6 +247,29 @@ export const zh = {
subagentDiagUnsupported: '不支持的条目',
subagentDiagUnavailable: '不可用',
subagentThinking: '思考中…',
sideChat: '侧边对话',
sideChatNew: '新建对话',
sideChatUntitled: '新对话',
sideChatEmpty: '暂无侧边对话',
sideChatEmptyDesc: '每个侧边对话是标签栏里的独立 Tab,继承当前会话的上下文运行,不会进入主会话',
sideChatCreating: '正在创建侧边对话…',
sideChatRetry: '重试',
sideChatThreads: '切换线程 / 新建',
sideChatSave: '保存为新会话',
sideChatSaveTitle: '把该线程提升为顶层会话,出现在主会话列表中',
sideChatSaved: '已保存为新会话',
sideChatNoTurn: '至少完成一轮对话后才能保存',
sideChatPendingDrop: '最后一条未完成的追问不会包含在新会话中',
sideChatFirstPlaceholder: '输入第一个问题,已继承当前会话上下文…',
sideChatComposerPlaceholder: '追问…',
sideChatThinking: '正在深入…',
sideChatThink: '思考过程',
sideChatSend: '发送',
sideChatCancel: '停止',
sideChatCancelTitle: '中止当前回合(保留队列)',
sideChatClose: '关闭线程',
sideChatCloseTitle: '释放线程的 agent(历史保留)',
sideChatError: '侧边对话出错:{message}',
jobs: '后台任务',
jobsCount: '{count} 个后台任务',
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
@@ -525,6 +548,29 @@ export const en: Record<keyof typeof zh, string> = {
subagentDiagUnsupported: 'Unsupported',
subagentDiagUnavailable: 'Unavailable',
subagentThinking: 'Thinking…',
sideChat: 'Side Chat',
sideChatNew: 'New thread',
sideChatUntitled: 'New thread',
sideChatEmpty: 'No side conversations',
sideChatEmptyDesc: 'Every side conversation is its own tab in the tab strip — it inherits the current session\'s context and never enters the main conversation',
sideChatCreating: 'Creating side conversation…',
sideChatRetry: 'Retry',
sideChatThreads: 'Switch thread / new',
sideChatSave: 'Save as new session',
sideChatSaveTitle: 'Promote this thread to a top-level session in the main session list',
sideChatSaved: 'Saved as a new session',
sideChatNoTurn: 'Save is available after the first completed turn',
sideChatPendingDrop: 'The last unanswered follow-up will not be included in the saved session',
sideChatFirstPlaceholder: 'Ask the first question — context inherited…',
sideChatComposerPlaceholder: 'Ask a follow-up…',
sideChatThinking: 'Deep diving…',
sideChatThink: 'Thinking',
sideChatSend: 'Send',
sideChatCancel: 'Stop',
sideChatCancelTitle: 'Abort the running turn (queued work is kept)',
sideChatClose: 'Close thread',
sideChatCloseTitle: 'Release the thread\'s agent (history is kept)',
sideChatError: 'Side Chat error: {message}',
jobs: 'Background jobs',
jobsCount: '{count} background jobs',
jobsCountRunning: '{count} background jobs · {running} running',
+264
View File
@@ -0,0 +1,264 @@
/**
* Side Chat transcript mapping (browser half): turns a thread child's
* history rows (`session.history` — the generic RPC, which reads the durable
* log without activating the child) into compact display rows.
*
* 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.
*
* Live streaming: `assistant/message` events only land when a step
* completes, but `assistant/chunk` events stream token-level text and
* reasoning deltas. The mapping accumulates both per block and supersedes
* 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'
/** One compact transcript row rendered in the thread view. `seq` is the
* source event's log sequence — stable row identity for React keys across
* polls (streaming caches ride the key, so window slides must not re-key
* rows). */
export type SidechatTranscriptRow =
| { kind: 'user'; 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 }
| { kind: 'reasoning'; seq: number; text: string; settled: boolean }
| {
kind: 'tool'
seq: number
name: string
failed: boolean
/** Raw arguments JSON as the model produced it. */
args?: string
/** Plain text of the paired result. */
resultText?: string
/** True while the call's result has not landed yet. */
executing?: boolean
}
/** Extract the visible text of a content-block list (`text` blocks verbatim,
* joined by blank lines); empty reads `…` so rows never render blank. */
export function blockText(content: readonly unknown[]): string {
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'text' && typeof candidate.text === 'string') {
parts.push(candidate.text)
}
}
const text = parts.join('\n\n')
return text === '' ? '…' : text
}
/** The plain text of a tool/result message (text blocks inside its
* `tool-result` content block). */
function resultTextOf(data: Record<string, unknown>): string {
const message = data.message as { content?: unknown } | undefined
const content = message?.content
if (!Array.isArray(content)) return ''
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; content?: unknown }
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') {
parts.push(textItem.text)
}
}
}
return parts.join('\n')
}
/** Index of the last `session/end-seed` event (fork seed marker), or -1. */
function lastSeedEnd(events: readonly { type: string }[]): number {
for (let index = events.length - 1; index >= 0; index--) {
if (events[index]?.type === 'session/end-seed') return index
}
return -1
}
/**
* Collect the thread's OWN events on first attach: walk backward from the
* log tail (oldest-first accumulation) until the `session/end-seed` marker
* surfaces, then keep everything after it.
*
* Page size matters: cold reads re-expand persisted chunk-rows into one
* `assistant/chunk` event per delta, so a single streamed answer can be
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
* earlier `tool/call` events fall out of the loaded window — the tool rows
* vanished on re-entry while the settled text survived. The walk therefore
* pages big; tail polls stay small.
*
* Exhaustion (log start reached without a marker — a thread created before
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
* caller stops re-walking and renders the window as-is.
*
* @param fetchPage - one history page (newest-first window ending at
* `beforeSeq`, exclusive; omit for the tail page).
* @param pageCap - safety bound on backward pages.
*/
export async function collectOwnEvents(
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
pageCap = 40,
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
const collected: SidebarHistoryEntry[] = []
let beforeSeq: number | undefined
for (let page = 0; page < pageCap; page++) {
const events = await fetchPage(beforeSeq)
if (events.length === 0) {
// Log start reached without a marker: the window IS the whole log.
return { seedBoundary: 0, entries: collected }
}
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
const fresh = olderThan === undefined
? [...events]
: events.filter(entry => entry.event.seq < olderThan)
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
if (seedEnd >= 0) {
collected.unshift(...fresh.slice(seedEnd + 1))
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
}
collected.unshift(...fresh)
if (fresh.length === 0) {
// The page overlaps entirely with what we have: nothing older exists.
return { seedBoundary: 0, entries: collected }
}
beforeSeq = fresh[0]!.event.seq
}
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
// rather than re-walking on every poll.
return { seedBoundary: 0, entries: collected }
}
/**
* 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).
* @param entries - history rows (event + host-computed view) in seq order.
* @returns display rows in log order.
*/
export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
const events = entries.map(entry => entry.event)
const seedEnd = lastSeedEnd(events)
const rows: SidechatTranscriptRow[] = []
/** (turn, step, index, kind) key → index of its accumulating stream row. */
const streamRows = new Map<string, number>()
/** tool callId → index of its tool row in `rows` (result pairing). */
const callRows = new Map<string, number>()
for (let index = 0; index < events.length; index++) {
if (index <= seedEnd) continue
const event = events[index]
if (event === undefined) continue
const data = event.data as Record<string, unknown>
switch (event.type) {
case 'user/message': {
const text = blockText(Array.isArray(data.content) ? data.content : [])
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) break
rows.push({ kind: 'user', seq: event.seq, text })
break
}
case 'assistant/chunk': {
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object') break
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break
const turn = data.turn
const step = data.step
const blockIndex = (chunk as { index?: unknown }).index
const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}`
const existing = streamRows.get(key)
if (existing !== undefined) {
const row = rows[existing]
if (row !== undefined && row.kind === kind && !row.settled) {
rows[existing] = { ...row, text: row.text + chunk.text }
}
} else {
streamRows.set(key, rows.length)
rows.push({ kind, seq: event.seq, text: chunk.text, settled: false })
}
break
}
case 'assistant/message': {
const prefix = `${String(data.turn)}:${String(data.step)}:`
const streamed = [...streamRows.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([, rowIndex]) => rowIndex)
for (const key of [...streamRows.keys()]) {
if (key.startsWith(prefix)) streamRows.delete(key)
}
const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content)
? (data.message as { content: readonly unknown[] }).content
: []
const settled: SidechatTranscriptRow[] = content.flatMap((block): SidechatTranscriptRow[] => {
if (block === null || typeof block !== 'object') return []
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'reasoning' && typeof candidate.text === 'string' && candidate.text !== '') {
return [{ kind: 'reasoning', seq: event.seq, text: candidate.text, settled: true }]
}
if (candidate.type === 'text' && typeof candidate.text === 'string' && candidate.text !== '') {
return [{ kind: 'assistant', seq: event.seq, text: candidate.text, settled: true }]
}
return []
})
if (streamed.length === 0) rows.push(...settled)
else rows.splice(Math.min(...streamed), streamed.length, ...settled)
break
}
case 'tool/call': {
const callId = data.callId
const name = typeof data.name === 'string' ? data.name : 'tool'
const args = typeof data.arguments === 'string' ? data.arguments : undefined
const rowIndex = rows.length
if (typeof callId === 'string') callRows.set(callId, rowIndex)
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
break
}
case 'tool/result': {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
const rowIndex = callId === undefined ? undefined : callRows.get(callId)
const failed = data.error !== undefined
const resultText = resultTextOf(data)
if (rowIndex !== undefined) {
const row = rows[rowIndex]
if (row !== undefined && row.kind === 'tool') {
rows[rowIndex] = {
...row,
failed: row.failed || failed,
resultText: resultText === '' ? row.resultText : resultText,
executing: false,
}
}
} else if (failed || resultText !== '') {
// Orphan result (no call row in the window): surface it so the row
// stays informative and expandable.
rows.push({
kind: 'tool',
seq: event.seq,
name: callId === undefined ? 'tool' : `tool:${callId.slice(0, 8)}`,
failed,
resultText: resultText === '' ? undefined : resultText,
})
}
break
}
default: {
break
}
}
}
return rows
}
+14 -2
View File
@@ -15,6 +15,17 @@ import type {
SidebarSessionSummary,
SidebarSubagentCatalog,
} from '../context-types.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
/**
* Side Chat threads ride the subagent origin (main-list hiding + the RPC
* ownership fence) but they are NOT subagent topology: they carry the
* durable 'Side: ' label and live as sidebar tabs. Excluding them here
* keeps the auto-open trigger and the Subagent page counts clean.
*/
export function isSideThreadSummary(summary: SidebarSessionSummary): boolean {
return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)
}
/** Count the direct subagent children of one session (durable `origin` rows). */
export function directSubagentCount(
@@ -23,7 +34,8 @@ export function directSubagentCount(
): number {
let count = 0
for (const summary of Object.values(byId)) {
if (summary.origin === 'subagent' && summary.parentId === sessionId) count += 1
if (summary.origin === 'subagent' && summary.parentId === sessionId
&& !isSideThreadSummary(summary)) count += 1
}
return count
}
@@ -107,7 +119,7 @@ export function countSubagentDescendants(
): SubagentDescendantTotals {
const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
for (const descendant of Object.values(byId)) {
if (descendant.origin !== 'subagent') continue
if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = descendant
while (current?.origin === 'subagent' && current.parentId !== undefined