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
+86 -1
View File
@@ -225,6 +225,45 @@ export interface SidebarJobsService {
export interface SidebarAgentsService {
/** The live agent registered under a session id, or undefined when not live. */
get(id: string): SidebarAgent | undefined
/**
* Create a session + agent with a custom seed (mirror of the runtime
* AgentRegistry.create) — the Side Chat thread-creation seam: the SAME
* public seam api-proxy's session.fork and the subagent fork provider use.
*/
create?(options: unknown): Promise<{ agent: SidebarAgent; dispose(): Promise<void> }>
/**
* Resume an agent on a persisted session (mirror of the runtime
* AgentRegistry.resume) — the Side Chat cold-continuation seam after a
* DSH restart or a closed thread.
*/
resume?(options: unknown): Promise<{ agent: SidebarAgent; dispose(): Promise<void> }>
}
/** The host agent-presets service face (mirror of the runtime agentPresets
* service): resolves and mounts the preset composition a session recorded,
* so a resumed or forked session rebuilds the same tool/prompt world its
* history was produced under. */
export interface SidebarAgentPresetsService {
/** Resolve a preset id; undefined resolves the deployment default. */
resolve(presetId?: string): Promise<{ id: string }>
/** Mount a preset's composition into an agent scope before publication. */
mount(agentCtx: unknown, presetId: string): Promise<void>
}
/** The host session-title service face (mirror of the sessionTitle service). */
export interface SidebarSessionTitleService {
/** Rename one live session's title (pins it against auto-regeneration). */
rename(session: unknown, title: string): { title: string; eventSeq: number }
}
/** The host session-persistence face (mirror of the sessionPersistence
* service): detached inspection of a persisted session, used to compose the
* recorded preset when a Side Chat thread cold-resumes. */
export interface SidebarSessionPersistenceService {
inspect(sessionId: string): Promise<{
meta: { cwd?: string; agentPreset?: string }
events: readonly SidebarSessionEvent[]
}>
}
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
@@ -236,9 +275,21 @@ export interface SidebarRpcResponse<T> {
result: SidebarRpcResult<T>
}
/** The generic session-history RPC face the Side Chat transcript polls
* (subagent.history verifies subagent-catalog membership, which our custom
* side-thread children do not have — the generic session.history reads any
* durable log directly). */
export interface SidebarSessionHistoryRpc {
history(
payload: { sessionId: string; beforeSeq?: number; maxMessages?: number },
signal?: AbortSignal,
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
}
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
export interface SidebarConnectionHandle {
api: {
sessions: SidebarSessionHistoryRpc
subagents: {
history(
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
@@ -273,6 +324,23 @@ export interface SidebarSessionsService {
* — used to jump back to the main agent from the topology root node.
*/
open?(id: string): void
/**
* Fork a session from a completed-turn prefix of the source and resolve
* the child session id (mirror of the runtime ISessions.fork — throws on
* failure). The Side Chat "save as new session" action uses this to
* promote a hidden side thread into a top-level session.
*/
fork?(opts: { sessionId: string; atSeq?: number; increaseTitle?: boolean }): Promise<string>
/**
* Resolve the stable session binding of one listed session (mirror of the
* runtime ISessions.binding); the saved-session rename uses the face's
* behavior verbs.
*/
binding?(id: string): {
session: {
rename(title: string): Promise<unknown>
}
} | undefined
/**
* Resolve an Agent-scoped context view for one session (mirror of the
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
@@ -442,9 +510,26 @@ declare module 'cordis' {
jobs: SidebarJobsService
/**
* The host live-agent registry (`ctx.get('agents')`; optional — used to
* resolve the caller the jobs fence compares against).
* resolve the caller the jobs fence compares against, and to create /
* resume the Side Chat thread agents).
*/
agents: SidebarAgentsService
/**
* The host agent-presets service (`ctx.get('agentPresets')`; optional —
* absent deployments compose nothing and every session shares the host
* composition).
*/
agentPresets: SidebarAgentPresetsService
/**
* The host session-title service (`ctx.get('sessionTitle')`; optional —
* the Side Chat thread label pin degrades to the auto-generated title).
*/
sessionTitle: SidebarSessionTitleService
/**
* The host session-persistence service (`ctx.get('sessionPersistence')`;
* optional — needed only for the Side Chat cold-resume composition).
*/
sessionPersistence: SidebarSessionPersistenceService
/**
* The client-side sidebar registry: external plugins register tab types
* and file previewers here. Provided by the client half (see
+9
View File
@@ -47,6 +47,7 @@ import {
} from './pty-deps.ts'
import { registerTools } from './tools.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { buildSidechatApi } from './sidechat-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config }
@@ -470,6 +471,14 @@ function buildApi(
clearTimeout(timer)
}
},
// Side Chat: create a side-thread child seeded with the parent's full
// log up to now, deliver follow-ups (cold-resuming when the thread's
// agent is gone), abort a running thread, and release a thread's agent.
// Every operation runs through these routes because subagent-origin
// identities are fenced from the generic session RPCs (agent-lookup
// ownership), and the thread is created with a CUSTOM seed the stock
// fork APIs cannot express.
...buildSidechatApi(ctx),
}
}
+444
View File
@@ -0,0 +1,444 @@
/**
* Pure side-conversation ("Side Chat") logic shared by the host routes and
* the client tab. Framework-free (no React, no Node) so both halves and the
* node test environment can import it.
*
* A side thread is a child session the plugin creates ITSELF with a custom
* seed — the parent session's FULL event log up to the click moment
* (completed turns, the unanswered user message, and — when the parent is
* mid-turn — the in-progress assistant output and tool activity). The log
* model forbids open-turn seeds, so an in-flight parent turn is copied
* verbatim and CLOSED with synthetic `step/end` + `turn/end{reason:
* 'interrupted'}` events: the child sees the partial turn as honestly
* frozen ("cut off"), never as a completed answer. The one case that cannot
* be closed honestly — a tool call still executing (no `tool/result` yet;
* providers reject dangling assistant calls) — falls back to cutting before
* the open turn and carrying the partial content as a structured text
* snapshot inside the boundary prompt.
*/
import type { SidebarHistoryEntry, SidebarSessionSummary } from './context-types.ts'
/** The durable thread-label prefix (also the row filter in the client list). */
export const SIDE_LABEL_PREFIX = 'Side: '
/** The pinned label of a freshly created thread that no prompt has reached
* yet (Codex-style immediate create: the tab opens an EMPTY thread, the
* first composer message carries the boundary and earns the real label).
* The client renders it localized; the prefix keeps the row filter honest. */
export const SIDE_NEW_THREAD_TITLE = 'Side: New thread'
/** Maximum code points kept in a durable thread label (matches subagent labels). */
export const LABEL_MAX_CHARS = 48
/** The boundary message's opening line — the transcript mapping drops user
* rows starting with it (same first line as dsh-sidechain's boundary, so
* the two plugins' threads render consistently in either UI). */
export const SIDE_BOUNDARY_PREFIX = 'Side conversation boundary'
/**
* The boundary prompt delivered as the thread's first user message: the
* inherited seed is reference context only, never active instruction.
* Model-facing contract — change only with intent, tests pin the sentences.
*/
export const SIDE_BOUNDARY_PROMPT = `Side conversation boundary.
Everything before this boundary is inherited history from the parent session: its completed turns, its pending question, and — if the parent was mid-turn — its in-progress output frozen at the moment this side conversation started. It is reference context only. It is not your current task.
Do not continue, execute, or complete any instructions, plans, tool calls, approvals, edits, or requests from before this boundary. Only messages submitted after this boundary are active user instructions for this side conversation.
Mode: this is a continuable side conversation. Your answers stay in this side thread and are viewed in the side panel; they are never delivered into the parent session.`
/** One seed event (structural mirror of the durable SessionEvent). The
* envelope fields are preserved verbatim: surface-eligible events
* (user/message, assistant/message, tool/result) REQUIRE the `surfaceOp`
* marker (and may carry `sourceEventSeqs`) — the seed validator rejects
* them without it. */
export interface SeedEvent {
type: string
seq: number
time: number
data: Record<string, unknown>
/** Surface marker of message-producing events ('append' | replace op). */
surfaceOp?: unknown
/** Seq numbers of earlier events this event cites as sources. */
sourceEventSeqs?: unknown
/** Reader-skip marker of purely informational events. */
ignorable?: true
}
/** The minimal structural face of a session-log event this module reads
* (loose enough to accept both the host's real SessionEvent and the
* client's SidebarSessionEvent mirror). */
export interface SidechatLogEvent {
type: string
seq: number
time: number
data: unknown
}
/** The result of cutting a parent log into a side-thread inheritance. */
export interface SidechatInheritance {
/** The child seed: contiguous from seq 0, ends outside any open turn. */
seed: SeedEvent[]
/**
* Structured snapshot of the parent's in-progress turn when it could NOT
* be included as events (a tool call was still executing); null when the
* seed already carries the whole picture.
*/
snapshot: string | null
}
/** The data record of one event (narrowed from the loose face). */
function dataOf(event: SidechatLogEvent): Record<string, unknown> {
return event.data as Record<string, unknown>
}
/** Copy parent events verbatim (their live seq === array index contract).
* The FULL envelope is preserved — stripping `surfaceOp` would make the
* seed validator reject every surface-eligible message event. */
function copyEvents(events: readonly SidechatLogEvent[]): SeedEvent[] {
return events.map(event => {
const source = event as SidechatLogEvent & {
surfaceOp?: unknown
sourceEventSeqs?: unknown
ignorable?: true
}
return {
type: source.type,
seq: source.seq,
time: source.time,
data: dataOf(source),
...(source.surfaceOp === undefined ? {} : { surfaceOp: source.surfaceOp }),
...(source.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: source.sourceEventSeqs }),
...(source.ignorable === undefined ? {} : { ignorable: source.ignorable }),
}
})
}
/** Index of the last `turn/start` or `turn/end`, or -1. */
function lastTurnBoundary(events: readonly SidechatLogEvent[]): number {
for (let index = events.length - 1; index >= 0; index--) {
const type = events[index]?.type
if (type === 'turn/start' || type === 'turn/end') return index
}
return -1
}
/** Numeric field of an event's data (turn / step numbers). */
function numberAt(data: Record<string, unknown>, key: string): number {
const value = data[key]
return typeof value === 'number' && Number.isSafeInteger(value) ? value : 0
}
/** The step number still open at the log tail inside the turn starting at
* `turnStart` (undefined when no step is open). */
function openStepInTurn(events: readonly SidechatLogEvent[], turnStart: number): number | undefined {
let open: number | undefined
for (let index = turnStart + 1; index < events.length; index++) {
const event = events[index]
if (event === undefined) continue
if (event.type === 'step/start') open = numberAt(dataOf(event), 'step')
else if (event.type === 'step/end') open = undefined
}
return open
}
/**
* Whether the open turn ending the log has a `tool/call` without its paired
* `tool/result` in the CURRENT open step. Providers reject dangling
* assistant calls, so such a turn cannot be honestly closed and the
* inheritance must fall back to the snapshot.
*/
export function hasDanglingToolCall(events: readonly SidechatLogEvent[], turnStart: number): boolean {
const pending = new Set<string>()
for (let index = turnStart + 1; index < events.length; index++) {
const event = events[index]
if (event === undefined) continue
const data = dataOf(event)
if (event.type === 'step/end') {
pending.clear()
continue
}
if (event.type === 'tool/call') {
const callId = data.callId
if (typeof callId === 'string') pending.add(callId)
continue
}
if (event.type === 'tool/result') {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = source?.source?.callId
if (typeof callId === 'string') pending.delete(callId)
}
}
return pending.size > 0
}
/** The plain text of one tool/result message (text blocks inside its
* `tool-result` content block). */
function toolResultText(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')
}
/** Cap applied to one tool-result's text inside a snapshot (prompt budget). */
const SNAPSHOT_RESULT_CAP = 2000
/** Cap applied to the whole snapshot (prompt budget). */
const SNAPSHOT_TOTAL_CAP = 8000
/**
* Build the side-thread inheritance for one parent log: the full event log
* up to the click moment, honestly closed when it ends inside an open turn.
*/
export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): SidechatInheritance {
if (events.length === 0) return { seed: [], snapshot: null }
const boundary = lastTurnBoundary(events)
if (boundary < 0 || events[boundary]?.type === 'turn/end') {
// Ends outside any turn (or has no turns at all): the whole log is a
// valid, balanced seed — possibly ending with a pending user message.
return { seed: copyEvents(events), snapshot: null }
}
// Ends inside the open turn starting at `boundary`.
if (hasDanglingToolCall(events, boundary)) {
// Cannot close honestly: cut before the open turn; the caller attaches
// the structured snapshot to the boundary prompt instead.
return {
seed: copyEvents(events.slice(0, boundary)),
snapshot: buildOpenTurnSnapshot(events),
}
}
const seed = copyEvents(events)
const last = events[events.length - 1]
const turn = numberAt(dataOf(events[boundary]!), 'turn')
const now = last?.time ?? 0
const openStep = openStepInTurn(events, boundary)
if (openStep !== undefined) {
seed.push({ type: 'step/end', seq: seed.length, time: now, data: { turn, step: openStep } })
}
seed.push({
type: 'turn/end',
seq: seed.length,
time: now,
data: { turn, reason: { kind: 'interrupted' } },
})
return { seed, snapshot: null }
}
/** The seed half of {@link buildSidechatInheritance} (test convenience). */
export function sidechatSeed(events: readonly SidechatLogEvent[]): SeedEvent[] {
return buildSidechatInheritance(events).seed
}
/**
* Structured text snapshot of the parent's OPEN turn (from its `turn/start`
* to the log tail): the accumulated assistant/reasoning output verbatim
* (code blocks ride the raw deltas) and the tool activity — executed tools
* with their result text, the still-executing one marked. Returns null when
* there is no open turn or nothing to show.
*/
export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): string | null {
const boundary = lastTurnBoundary(events)
if (boundary < 0 || events[boundary]?.type !== 'turn/start') return null
let text = ''
let reasoning = ''
const tools: string[] = []
const pendingCalls = new Map<string, { name: string; args: string }>()
let total = 0
for (let index = boundary + 1; index < events.length; index++) {
const event = events[index]
if (event === undefined) continue
const data = dataOf(event)
if (event.type === 'step/end') {
pendingCalls.clear()
continue
}
if (event.type === 'assistant/chunk') {
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object') continue
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') reasoning += chunk.text
continue
}
if (event.type === 'tool/call') {
const callId = data.callId
if (typeof callId === 'string') {
pendingCalls.set(callId, {
name: typeof data.name === 'string' ? data.name : 'tool',
args: typeof data.arguments === 'string' ? data.arguments : '',
})
}
continue
}
if (event.type === 'tool/result') {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
const name = callId !== undefined ? pendingCalls.get(callId)?.name : undefined
const args = callId !== undefined ? pendingCalls.get(callId)?.args : undefined
if (callId !== undefined) pendingCalls.delete(callId)
const result = toolResultText(data).slice(0, SNAPSHOT_RESULT_CAP)
const failed = data.error !== undefined
const line = [
`- \`${name ?? 'tool'}\`${failed ? ' (failed)' : ''}`
+ (args !== undefined && args !== '' ? ` — arguments: \`${args}\`` : ''),
...(result === '' ? [] : [` Result: ${result}`]),
].join('\n')
tools.push(line)
total += line.length
}
}
for (const [, call] of pendingCalls) {
const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\``
tools.push(line)
total += line.length
}
const sections: string[] = []
if (text.trim() !== '') sections.push(`Assistant output so far:\n\n${text}`)
if (reasoning.trim() !== '') sections.push(`Reasoning so far:\n\n${reasoning}`)
if (tools.length > 0) sections.push(`Tool activity:\n${tools.join('\n')}`)
if (sections.length === 0) return null
const body = sections.join('\n\n')
return body.length > SNAPSHOT_TOTAL_CAP
? `Parent session in-progress turn (reference only):\n\n${body.slice(0, SNAPSHOT_TOTAL_CAP)}…`
: `Parent session in-progress turn (reference only):\n\n${body}`
}
/** One side-thread row in the client's thread list. */
export interface SideThreadRow {
id: string
/** The durable thread title ('Side: …'). */
title: string
/** Whether the thread's agent is currently running. */
running: boolean
}
/**
* Derive the side threads of one parent session from the client session list:
* durable `origin: 'subagent'` children of the parent whose pinned title
* carries the thread label prefix (our creation path pins it via
* sessionTitle.rename; dsh-sidechain threads share the convention, so they
* are visible here too).
*/
export function sideThreadRows(
byId: Readonly<Record<string, SidebarSessionSummary>>,
sessionId: string,
): SideThreadRow[] {
const rows: SideThreadRow[] = []
for (const summary of Object.values(byId)) {
if (summary.origin !== 'subagent' || summary.parentId !== sessionId) continue
if (!summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)) continue
rows.push({ id: summary.id, title: summary.displayTitle, running: summary.running === true })
}
return rows
}
/** Truncate + prefix a question into a durable thread label. */
export function sideLabel(question: string): string {
const flat = question.replace(/\s+/g, ' ').trim()
const max = Math.max(1, LABEL_MAX_CHARS - SIDE_LABEL_PREFIX.length)
const body = flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
return `${SIDE_LABEL_PREFIX}${body}`
}
/**
* Whether the thread log already carries the side boundary message — i.e.
* the first prompt was delivered. Tolerant to the content shape (block
* array or bare string) and to inherited seed messages (only an OWN
* boundary message starts with the prefix; seed messages came from the
* parent's log, which never contains one).
*/
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
}
return false
}
/** 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). */
live: boolean
/** Live lifecycle state; absent on cold threads. */
status?: 'idle' | 'running'
/** Provider route of the live agent. */
provider?: string
/** Model id of the live agent. */
model?: string
/** The recorded agent preset (live header, or persisted on cold reads). */
preset?: string
}
/** The events a thread produced itself: everything after the LAST
* `session/end-seed` marker (the fork-seed boundary). */
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
const events = entries.map(entry => entry.event)
for (let index = events.length - 1; index >= 0; index--) {
if (events[index]?.type === 'session/end-seed') return events.slice(index + 1)
}
return events
}
/**
* Whether the thread has at least one completed turn — the save-as-new-
* session precondition (`session.fork` refuses to fork before the first
* `turn/end`).
*/
export function threadHasCompletedTurn(entries: readonly SidebarHistoryEntry[]): boolean {
return threadOwnEvents(entries).some(event => event.type === 'turn/end')
}
/** Whether the thread ends with a user message that no completed turn
* answered yet — such a pending follow-up is NOT carried into the saved
* session (the fork cut is the last `turn/end`). */
export function threadTrailingPending(entries: readonly SidebarHistoryEntry[]): boolean {
const own = threadOwnEvents(entries)
let lastUser = -1
let lastTurnEnd = -1
own.forEach((event, index) => {
if (event.type === 'user/message') lastUser = index
if (event.type === 'turn/end') lastTurnEnd = index
})
return lastUser > lastTurnEnd
}
/**
* The agent preset a session actually runs: newest `agent-preset/selected`
* event wins, else the creation header (mirror of the dsh-agent-presets
* resolveSessionPreset helper — replicated here to avoid a host dependency
* on that package).
*/
export function resolvePresetId(
header: { agentPreset?: string },
events: readonly SidechatLogEvent[],
): string | undefined {
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index]
if (event?.type !== 'agent-preset/selected') continue
const preset = dataOf(event).agentPreset
if (typeof preset === 'string') return preset
}
return header.agentPreset
}
+325
View File
@@ -0,0 +1,325 @@
/**
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
*
* A side thread is a child session the plugin creates ITSELF with a custom
* seed — the parent's full event log up to the click moment, honestly closed
* at an in-progress turn (see sidechat-core.ts). The child is marked
* `origin: 'subagent'` so the main session list hides it, and EVERY
* operation goes through these routes because the generic session RPCs are
* fenced away from subagent-origin identities (the api-remotes
* agent-lookup ownership fence). No DSH source is touched:
*
* - creation uses the public AgentRegistry.create seam (the same one
* api-proxy's session.fork and the subagent fork provider use), with the
* parent's preset composition and provider/model selection so the child's
* first request shares the parent's token prefix (provider-side prefix
* cache reuse);
* - the first prompt (boundary + question) and every follow-up are admitted
* with the stock `agent.followup`;
* - a cold thread (DSH restart, or a closed thread) is resumed with
* AgentRegistry.resume, composing the preset the child recorded.
*/
import { randomUUID } from 'node:crypto'
import { createUserMessage, type ContentBlock, type UserMessage } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { Context as CordisContext } from '@deepseek-ai/cordis'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {
Context,
SidebarAgentPresetsService,
SidebarSessionPersistenceService,
SidebarSessionTitleService,
} from './context-types.ts'
import {
boundaryDelivered,
buildSidechatInheritance,
resolvePresetId,
SIDE_BOUNDARY_PROMPT,
SIDE_NEW_THREAD_TITLE,
sideLabel,
type SeedEvent,
type SidechatLogEvent,
type SidechatThreadInfo,
} from './sidechat-core.ts'
import { requireString, SidebarError } from './wire.ts'
/** The five Side Chat routes of the sidebar API (wire method names). */
export interface SidechatRoutes {
/** Create a side thread child seeded with the parent's log up to now.
* `question` is optional: empty creates an EMPTY thread (Codex-style
* immediate create); the first `sidechat.prompt` then carries the
* boundary + snapshot and earns the thread its real label. */
'sidechat.start'(payload: unknown): Promise<{ childId: string }>
/** Deliver one follow-up message to a thread (live, or cold-resumed). */
'sidechat.prompt'(payload: unknown): Promise<{ accepted: true }>
/** Abort the thread's running turn (queued work is preserved). */
'sidechat.cancel'(payload: unknown): Promise<{ accepted: true }>
/** Release the thread's live agent (session and history stay persisted). */
'sidechat.dispose'(payload: unknown): Promise<{ accepted: true }>
/** Live state + agent identity for the thread header. */
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
}
/** Timeout guarding the create call (the registry detaches it before the
* handle becomes visible, so the child is never cancelled by it). */
const CREATE_TIMEOUT_MS = 15_000
/** Per-activation disposers of created thread agents (the dispose route
* releases them; the session and its history always stay persisted). */
const threadDisposers = new Map<string, () => Promise<void>>()
/** The in-progress-turn snapshot captured at creation of an EMPTY thread,
* waiting to ride the first prompt (lost on a host restart — the boundary
* prompt is then delivered alone, a logged degradation). */
const pendingSnapshots = new Map<string, string>()
/** Resolve the parent's preset and build the child's composition setup
* (mirror of api-proxy's composeAgent minus the model-selection install —
* the child carries the parent's provider/model in agentOptions). */
async function composeChildSetup(
ctx: Context,
presetId: string | undefined,
): Promise<{ agentPreset?: string; setup: AgentSetup }> {
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
if (presets === undefined) {
return { setup: () => Promise.resolve() }
}
const resolved = await presets.resolve(presetId)
return {
agentPreset: resolved.id,
setup: async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) },
}
}
/** Build the cold-resume setup from the thread's PERSISTED record (the
* recorded preset wins, newest selection event first). */
async function composePersistedSetup(
ctx: Context,
childId: string,
): Promise<AgentSetup> {
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence === undefined) {
return () => Promise.resolve()
}
const inspected = await persistence.inspect(childId)
const presetId = resolvePresetId(inspected.meta, inspected.events)
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
if (presets === undefined || presetId === undefined) {
return () => Promise.resolve()
}
const resolved = await presets.resolve(presetId)
return async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) }
}
/** One text-block prompt (the thread boundary + question, or a follow-up). */
function textPrompt(text: string): ContentBlock[] {
return [{ type: 'text', text }]
}
/** Admit one user message to a live agent through the stock followup path. */
function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
const message: UserMessage = createUserMessage({ content: blocks, source: { kind: 'user' } })
agent.followup(message)
}
/** 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
return agents?.get(childId)
}
/** Build the Side Chat routes (all optional services degrade to a wire
* error the tab surfaces inline). The record keys are the FULL wire method
* names the /sidebar/api dispatcher looks up (`api[method]`). */
export function buildSidechatApi(ctx: Context): SidechatRoutes {
return {
'sidechat.start': async (payload: unknown) => {
const sessionId = requireString(payload, 'sessionId')
const rawQuestion = (payload as { question?: unknown }).question
const question = typeof rawQuestion === 'string' ? rawQuestion.trim() : ''
const parent = liveThreadAgent(ctx, sessionId)
if (parent === undefined) {
throw new SidebarError('sidechat-error', `parent session "${sessionId}" is not running`, 409)
}
const parentSession = parent.session
const inheritance = buildSidechatInheritance(
parentSession.events as unknown as readonly SidechatLogEvent[],
)
const { agentPreset, setup } = await composeChildSetup(
ctx,
resolvePresetId(parentSession.header, parentSession.events),
)
const childId = `session-${randomUUID()}` as SessionId
const label = question === '' ? SIDE_NEW_THREAD_TITLE : sideLabel(question)
// Honest catalog citizenship: the durable descriptor keeps the thread
// a HEALTHY row in the host's subagents.list — a cold child without
// one is deterministically rendered as a 'corrupt' diagnostic. The
// SubagentView filters the 'Side: ' label out, so the topology UI
// stays noise-free; the row only serves enumeration correctness.
const descriptor = snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'sidechat',
label,
...(parent.options.provider === undefined ? {} : { agentProvider: parent.options.provider }),
...(parent.options.model === undefined ? {} : { agentModel: parent.options.model }),
})
const descriptorEvent: SeedEvent = {
type: 'subagent/descriptor',
seq: inheritance.seed.length,
time: Date.now(),
data: descriptor as unknown as Record<string, unknown>,
}
const seed = [...inheritance.seed, descriptorEvent]
const options: CreateAgentOptions = {
sessionId: childId,
meta: {
...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
parentSession: parentSession.id,
seedLength: seed.length,
origin: 'subagent',
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
...(agentPreset === undefined ? {} : { agentPreset }),
},
seed: seed as unknown as readonly SessionEvent[],
agentOptions: { ...parent.options },
setup,
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
}
const agents = ctx.get('agents') as { create(options: CreateAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
if (agents?.create === undefined) {
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
}
let handle: { agent: Agent; dispose(): Promise<void> }
try {
handle = await agents.create(options)
} catch (error) {
throw new SidebarError('sidechat-error', `thread creation failed: ${error instanceof Error ? error.message : String(error)}`, 500)
}
threadDisposers.set(childId, () => handle.dispose())
// Pin the thread label so the client can identify its threads by
// title prefix (the rename is a live-session op, no RPC fence).
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
const pinTitle = (label: string): void => {
if (titles === undefined) return
try {
titles.rename(handle.agent.session, label)
} catch {
// Keep the auto-generated title; the thread stays usable.
}
}
if (question === '') {
// Codex-style immediate create: no prompt yet — the composer owns
// the first message; the snapshot waits for it.
if (inheritance.snapshot !== null) pendingSnapshots.set(childId, inheritance.snapshot)
pinTitle(SIDE_NEW_THREAD_TITLE)
} 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')))
pinTitle(sideLabel(question))
}
return { childId }
},
'sidechat.prompt': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const text = requireString(payload, 'text').trim()
if (text === '') {
throw new SidebarError('bad-request', 'text is required')
}
let agent = liveThreadAgent(ctx, childId)
if (agent === undefined) {
// Cold thread: resume the persisted session under its recorded
// composition, then deliver the follow-up.
const agents = ctx.get('agents') as { resume(options: ResumeAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
if (agents?.resume === undefined) {
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
}
const setup = await composePersistedSetup(ctx, childId)
try {
const handle = await agents.resume({ resumeSessionId: childId as SessionId, setup })
threadDisposers.set(childId, () => handle.dispose())
agent = handle.agent
} catch (error) {
throw new SidebarError('sidechat-error', `thread resume failed: ${error instanceof Error ? error.message : String(error)}`, 500)
}
}
if (boundaryDelivered(agent.session.events as unknown as readonly SidechatLogEvent[])) {
admitFollowup(agent, textPrompt(text))
} else {
// First message of an immediately-created thread: it carries the
// boundary (+ the snapshot parked at creation, if still around)
// and earns the thread its real label.
const parts = [SIDE_BOUNDARY_PROMPT]
const snapshot = pendingSnapshots.get(childId)
pendingSnapshots.delete(childId)
if (snapshot !== undefined) parts.push(snapshot)
parts.push(text)
admitFollowup(agent, textPrompt(parts.join('\n\n')))
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
if (titles !== undefined) {
try {
titles.rename(agent.session, sideLabel(text))
} catch {
// Keep the placeholder title; the thread stays usable.
}
}
}
return { accepted: true as const }
},
'sidechat.cancel': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
agent.cancel({ kind: 'user' }, { keepInbox: true })
}
return { accepted: true as const }
},
'sidechat.dispose': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
pendingSnapshots.delete(childId)
const dispose = threadDisposers.get(childId)
if (dispose !== undefined) {
threadDisposers.delete(childId)
try {
await dispose()
} catch {
// The agent may already be gone (restart); the session persists.
}
}
return { accepted: true as const }
},
'sidechat.info': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
const preset = agent.session.header.agentPreset
return {
live: true,
status: agent.status,
...(agent.options.provider === undefined ? {} : { provider: agent.options.provider }),
...(agent.options.model === undefined ? {} : { model: agent.options.model }),
...(preset === undefined ? {} : { preset }),
}
}
// Cold thread: only the persisted preset is worth reading back.
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence !== undefined) {
try {
const inspected = await persistence.inspect(childId)
const preset = resolvePresetId(inspected.meta, inspected.events)
return { live: false, ...(preset === undefined ? {} : { preset }) }
} catch {
// Unknown/gone session: report a bare cold info.
}
}
return { live: false }
},
}
}
+1
View File
@@ -18,6 +18,7 @@ export type SidebarErrorCode =
| 'pty-error'
| 'pty-deps-missing'
| 'job-error'
| 'sidechat-error'
| 'settings-rejected'
| 'settings-conflict'
| 'internal'