feat(sidechat): 侧边对话——每对话一 Tab(Codex 对齐)+ 完整上下文继承 + 保存为新会话 (#286)
三轮实测驱动迭代:种子信封透传(surfaceOp 校验)、每线程一 Tab 交互重做完、四连修(fork 丢 this / 自绘 history+save 图标 / collectOwnEvents 大页回源修重开丢工具行 / descriptor 入种子修目录 corrupt 行 + 前缀过滤保拓扑零噪音)。739 单测 + 挂载冒烟 12/12 全绿。
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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', {}),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user