@keith9681/dsh-sidebar 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/README_EN.md +0 -0
- package/cordis.patch.yml +49 -0
- package/lib/client-editor.js +34651 -0
- package/lib/client-mermaid.js +200629 -0
- package/lib/client-registry.js +13388 -0
- package/lib/client-terminal.js +8904 -0
- package/lib/client.js +13388 -0
- package/lib/index.js +3959 -0
- package/lib/invariant.js +22 -0
- package/lib/types/agent-pty.d.ts +226 -0
- package/lib/types/browser-probe.d.ts +15 -0
- package/lib/types/bundle-route.d.ts +12 -0
- package/lib/types/client/BrowserView.d.ts +22 -0
- package/lib/types/client/DiffTab.d.ts +6 -0
- package/lib/types/client/DiffView.d.ts +51 -0
- package/lib/types/client/EditorHost.d.ts +12 -0
- package/lib/types/client/FileTree.d.ts +36 -0
- package/lib/types/client/GitView.d.ts +8 -0
- package/lib/types/client/OrphanedTab.d.ts +11 -0
- package/lib/types/client/PdfView.d.ts +6 -0
- package/lib/types/client/RenderBoundary.d.ts +32 -0
- package/lib/types/client/SandboxStatusBar.d.ts +10 -0
- package/lib/types/client/SideCardSection.d.ts +74 -0
- package/lib/types/client/SideChatView.d.ts +16 -0
- package/lib/types/client/Sidebar.d.ts +6 -0
- package/lib/types/client/SubagentView.d.ts +16 -0
- package/lib/types/client/TabBar.d.ts +43 -0
- package/lib/types/client/TerminalView.d.ts +24 -0
- package/lib/types/client/TextEditor.d.ts +10 -0
- package/lib/types/client/TreePanel.d.ts +23 -0
- package/lib/types/client/UploadOverlay.d.ts +20 -0
- package/lib/types/client/add-plugin-modal.d.ts +15 -0
- package/lib/types/client/api.d.ts +268 -0
- package/lib/types/client/binary-download.d.ts +12 -0
- package/lib/types/client/breakpoints.d.ts +12 -0
- package/lib/types/client/browser.d.ts +50 -0
- package/lib/types/client/builtins/index.d.ts +18 -0
- package/lib/types/client/builtins/tabs.d.ts +11 -0
- package/lib/types/client/builtins/viewers.d.ts +3 -0
- package/lib/types/client/chunk-loader.d.ts +124 -0
- package/lib/types/client/chunks/editor.d.ts +9 -0
- package/lib/types/client/chunks/mermaid.d.ts +10 -0
- package/lib/types/client/chunks/terminal.d.ts +9 -0
- package/lib/types/client/cm-themes.d.ts +24 -0
- package/lib/types/client/conversation-draft.d.ts +14 -0
- package/lib/types/client/desktop-env.d.ts +35 -0
- package/lib/types/client/editable-state.d.ts +27 -0
- package/lib/types/client/editor-load.d.ts +66 -0
- package/lib/types/client/frame-batcher.d.ts +24 -0
- package/lib/types/client/icons.d.ts +57 -0
- package/lib/types/client/image-types.d.ts +3 -0
- package/lib/types/client/ime-guard.d.ts +36 -0
- package/lib/types/client/index.d.ts +19 -0
- package/lib/types/client/intercept.d.ts +33 -0
- package/lib/types/client/lang.d.ts +12 -0
- package/lib/types/client/lazy-chunk.d.ts +25 -0
- package/lib/types/client/link-intercept.d.ts +42 -0
- package/lib/types/client/locales.d.ts +366 -0
- package/lib/types/client/mermaid-blocks.d.ts +41 -0
- package/lib/types/client/mermaid-sanitize.d.ts +2 -0
- package/lib/types/client/mermaid.d.ts +12 -0
- package/lib/types/client/open-when-sized.d.ts +20 -0
- package/lib/types/client/open-with-settings.d.ts +4 -0
- package/lib/types/client/open-with.d.ts +87 -0
- package/lib/types/client/openpath-intercept.d.ts +41 -0
- package/lib/types/client/paths.d.ts +35 -0
- package/lib/types/client/pdf-types.d.ts +2 -0
- package/lib/types/client/plugin-settings.d.ts +8 -0
- package/lib/types/client/plugins-shared.d.ts +27 -0
- package/lib/types/client/plugins-tabs.d.ts +3 -0
- package/lib/types/client/plugins-viewers.d.ts +3 -0
- package/lib/types/client/prefs.d.ts +41 -0
- package/lib/types/client/produced-files.d.ts +23 -0
- package/lib/types/client/selection-payload.d.ts +27 -0
- package/lib/types/client/service.d.ts +440 -0
- package/lib/types/client/settings-nav-icon.d.ts +19 -0
- package/lib/types/client/shell-presets.d.ts +48 -0
- package/lib/types/client/sidechat-transcript.d.ts +103 -0
- package/lib/types/client/split-pane.d.ts +30 -0
- package/lib/types/client/state.d.ts +359 -0
- package/lib/types/client/subagent-detect.d.ts +54 -0
- package/lib/types/client/subagent-jobs.d.ts +63 -0
- package/lib/types/client/tab-content-memo.d.ts +33 -0
- package/lib/types/client/terminal-font.d.ts +20 -0
- package/lib/types/client/theme.d.ts +45 -0
- package/lib/types/client/titlebar-strip.d.ts +23 -0
- package/lib/types/client/upload.d.ts +69 -0
- package/lib/types/client/wco.d.ts +47 -0
- package/lib/types/config.d.ts +64 -0
- package/lib/types/context-types.d.ts +603 -0
- package/lib/types/fs-operations.d.ts +28 -0
- package/lib/types/fs-search.d.ts +24 -0
- package/lib/types/fs-tree.d.ts +51 -0
- package/lib/types/git.d.ts +82 -0
- package/lib/types/html-route.d.ts +59 -0
- package/lib/types/index.d.ts +47 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/jobs-routes.d.ts +45 -0
- package/lib/types/open-external.d.ts +24 -0
- package/lib/types/prefs-shared.d.ts +225 -0
- package/lib/types/pty-deps.d.ts +78 -0
- package/lib/types/pty-manager.d.ts +168 -0
- package/lib/types/sidechat-core.d.ts +172 -0
- package/lib/types/sidechat-routes.d.ts +30 -0
- package/lib/types/subagent-activity.d.ts +44 -0
- package/lib/types/subagent-live-route.d.ts +44 -0
- package/lib/types/tools.d.ts +31 -0
- package/lib/types/trust-fence.d.ts +24 -0
- package/lib/types/wire.d.ts +38 -0
- package/package.json +173 -0
- package/scripts/install.ps1 +289 -0
- package/scripts/install.sh +287 -0
- package/src/agent-pty.ts +527 -0
- package/src/browser-probe.ts +26 -0
- package/src/bundle-route.ts +129 -0
- package/src/client/BrowserView.tsx +253 -0
- package/src/client/DiffTab.tsx +110 -0
- package/src/client/DiffView.tsx +305 -0
- package/src/client/EditorHost.tsx +525 -0
- package/src/client/FileTree.tsx +653 -0
- package/src/client/GitView.tsx +558 -0
- package/src/client/OrphanedTab.tsx +27 -0
- package/src/client/PdfView.tsx +110 -0
- package/src/client/RenderBoundary.tsx +49 -0
- package/src/client/SandboxStatusBar.tsx +60 -0
- package/src/client/SideCardSection.module.css +960 -0
- package/src/client/SideCardSection.tsx +1152 -0
- package/src/client/SideChatView.module.css +437 -0
- package/src/client/SideChatView.tsx +644 -0
- package/src/client/Sidebar.tsx +1148 -0
- package/src/client/SubagentView.module.css +547 -0
- package/src/client/SubagentView.tsx +883 -0
- package/src/client/TabBar.tsx +305 -0
- package/src/client/TerminalView.tsx +383 -0
- package/src/client/TextEditor.tsx +500 -0
- package/src/client/TreePanel.tsx +273 -0
- package/src/client/UploadOverlay.tsx +62 -0
- package/src/client/add-plugin-modal.tsx +199 -0
- package/src/client/api.ts +335 -0
- package/src/client/binary-download.tsx +23 -0
- package/src/client/breakpoints.ts +51 -0
- package/src/client/browser.ts +119 -0
- package/src/client/builtins/index.ts +37 -0
- package/src/client/builtins/tabs.tsx +331 -0
- package/src/client/builtins/viewers.tsx +125 -0
- package/src/client/chunk-loader.ts +351 -0
- package/src/client/chunks/editor.tsx +9 -0
- package/src/client/chunks/mermaid.tsx +10 -0
- package/src/client/chunks/terminal.tsx +9 -0
- package/src/client/cm-themes.ts +129 -0
- package/src/client/conversation-draft.ts +29 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/desktop-env.ts +72 -0
- package/src/client/editable-state.ts +70 -0
- package/src/client/editor-load.ts +92 -0
- package/src/client/frame-batcher.ts +56 -0
- package/src/client/icons.tsx +161 -0
- package/src/client/image-types.ts +8 -0
- package/src/client/ime-guard.ts +47 -0
- package/src/client/index.tsx +369 -0
- package/src/client/intercept.tsx +110 -0
- package/src/client/lang.ts +103 -0
- package/src/client/layout.css +94 -0
- package/src/client/lazy-chunk.tsx +89 -0
- package/src/client/link-intercept.ts +73 -0
- package/src/client/locales.ts +734 -0
- package/src/client/mermaid-blocks.ts +110 -0
- package/src/client/mermaid-sanitize.ts +93 -0
- package/src/client/mermaid.tsx +401 -0
- package/src/client/open-when-sized.ts +43 -0
- package/src/client/open-with-settings.tsx +135 -0
- package/src/client/open-with.ts +221 -0
- package/src/client/openpath-intercept.ts +62 -0
- package/src/client/paths.ts +47 -0
- package/src/client/pdf-types.ts +4 -0
- package/src/client/plugin-settings.ts +43 -0
- package/src/client/plugins-shared.ts +29 -0
- package/src/client/plugins-tabs.ts +87 -0
- package/src/client/plugins-viewers.ts +30 -0
- package/src/client/prefs.ts +207 -0
- package/src/client/produced-files.ts +91 -0
- package/src/client/selection-payload.ts +86 -0
- package/src/client/service.ts +838 -0
- package/src/client/settings-nav-icon.ts +45 -0
- package/src/client/shell-presets.ts +83 -0
- package/src/client/sidebar.module.css +2577 -0
- package/src/client/sidechat-transcript.ts +321 -0
- package/src/client/split-pane.tsx +322 -0
- package/src/client/state.ts +1175 -0
- package/src/client/subagent-detect.ts +137 -0
- package/src/client/subagent-jobs.ts +161 -0
- package/src/client/tab-content-memo.ts +46 -0
- package/src/client/terminal-font.ts +28 -0
- package/src/client/theme.ts +110 -0
- package/src/client/titlebar-strip.ts +37 -0
- package/src/client/upload.ts +187 -0
- package/src/client/wco.ts +120 -0
- package/src/config.ts +155 -0
- package/src/context-types.ts +610 -0
- package/src/fs-operations.ts +99 -0
- package/src/fs-search.ts +86 -0
- package/src/fs-tree.ts +158 -0
- package/src/git.ts +244 -0
- package/src/html-route.ts +106 -0
- package/src/index.ts +1132 -0
- package/src/invariant.ts +32 -0
- package/src/jobs-routes.ts +257 -0
- package/src/open-external.ts +90 -0
- package/src/prefs-shared.ts +268 -0
- package/src/pty-deps.ts +240 -0
- package/src/pty-manager.ts +376 -0
- package/src/sidechat-core.ts +468 -0
- package/src/sidechat-routes.ts +344 -0
- package/src/subagent-activity.ts +91 -0
- package/src/subagent-live-route.ts +93 -0
- package/src/tools.ts +479 -0
- package/src/trust-fence.ts +77 -0
- package/src/wire.ts +100 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Side Chat page: Codex-style side conversations for the current session.
|
|
3
|
+
*
|
|
4
|
+
* EVERY side conversation is its own sidebar tab (侧边对话1/2/3 …): the
|
|
5
|
+
* descriptor's createTab mints a fresh tab flagged `autoCreate` and this
|
|
6
|
+
* view creates the EMPTY thread on mount (one click = one conversation,
|
|
7
|
+
* exactly like the Codex app); the composer owns the first message (the
|
|
8
|
+
* host wraps it with the side boundary + the in-progress snapshot parked
|
|
9
|
+
* at creation, and the thread earns its real label — and the tab its
|
|
10
|
+
* title — from that first message). Closing the tab releases the thread's
|
|
11
|
+
* live agent (its history stays persisted); the header menu reopens any
|
|
12
|
+
* existing thread into a tab (deduped by threadId).
|
|
13
|
+
*
|
|
14
|
+
* Each side thread is a child session the plugin created itself with a
|
|
15
|
+
* custom seed (the parent's full log up to the click moment — see
|
|
16
|
+
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
|
|
17
|
+
* info go through the plugin's own /sidebar/api sidechat.* routes
|
|
18
|
+
* (subagent-origin identities are fenced from the generic session RPCs);
|
|
19
|
+
* the transcript is polled from the generic session.history RPC (seed-cut
|
|
20
|
+
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
|
|
21
|
+
* — see sidechat-transcript.ts.
|
|
22
|
+
*/
|
|
23
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
24
|
+
import { useSyncExternalStore } from 'react'
|
|
25
|
+
import clsx from 'clsx'
|
|
26
|
+
import {
|
|
27
|
+
IconChevronRightOutline14,
|
|
28
|
+
IconNewChatOutline16,
|
|
29
|
+
IconPlusOutline16,
|
|
30
|
+
IconSendOutline16,
|
|
31
|
+
IconStopFill16,
|
|
32
|
+
MarkdownText,
|
|
33
|
+
Menu,
|
|
34
|
+
StateDot,
|
|
35
|
+
type MenuEntry,
|
|
36
|
+
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
37
|
+
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
|
|
38
|
+
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
|
|
39
|
+
import {
|
|
40
|
+
SIDE_LABEL_PREFIX,
|
|
41
|
+
SIDE_NEW_THREAD_TITLE,
|
|
42
|
+
sideThreadRows,
|
|
43
|
+
threadHasCompletedTurn,
|
|
44
|
+
threadTrailingPending,
|
|
45
|
+
type SidechatThreadInfo,
|
|
46
|
+
} from '../sidechat-core.ts'
|
|
47
|
+
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
|
|
48
|
+
import { api } from './api.ts'
|
|
49
|
+
import { t } from './locales.ts'
|
|
50
|
+
import type { SessionScope } from './api.ts'
|
|
51
|
+
import type { SidebarTab } from './state.ts'
|
|
52
|
+
import css from './SideChatView.module.css'
|
|
53
|
+
|
|
54
|
+
/** Tail-page size for one transcript poll (events per page). Small on
|
|
55
|
+
* purpose: streaming polls ride the tail and merge by seq. */
|
|
56
|
+
const PAGE_MESSAGES = 8
|
|
57
|
+
/** First-attach walk page size: cold reads re-expand chunk-rows into one
|
|
58
|
+
* event per streamed delta, so a single answer can be hundreds of events —
|
|
59
|
+
* the walk must page big or earlier tool/call rows fall out of the window. */
|
|
60
|
+
const WALK_PAGE_EVENTS = 200
|
|
61
|
+
/** Poll cadence while the selected thread is running and the tab visible. */
|
|
62
|
+
const POLL_MS = 2000
|
|
63
|
+
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
|
|
64
|
+
const COMPOSER_MAX_HEIGHT = 132
|
|
65
|
+
|
|
66
|
+
/** The thread a tab is bound to (durable in tab.meta across refreshes). */
|
|
67
|
+
export function sidechatThreadIdOf(tab: SidebarTab): string | undefined {
|
|
68
|
+
const meta = tab.meta as { threadId?: unknown } | undefined
|
|
69
|
+
return typeof meta?.threadId === 'string' ? meta.threadId : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The parked reopen target consumed by the descriptor's createTab (the
|
|
73
|
+
* service's createTab receives no seed, so a thread-switch parks the id
|
|
74
|
+
* here and openTab picks it up synchronously — exactly one consume per
|
|
75
|
+
* park). */
|
|
76
|
+
let parkedReopen: string | undefined
|
|
77
|
+
|
|
78
|
+
/** Park a thread id for the NEXT sidechat openTab to reattach. */
|
|
79
|
+
export function parkSidechatReopen(threadId: string): void {
|
|
80
|
+
parkedReopen = threadId
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Consume the parked reopen target (undefined = mint a fresh thread tab). */
|
|
84
|
+
export function consumeSidechatSeed(): string | undefined {
|
|
85
|
+
const value = parkedReopen
|
|
86
|
+
parkedReopen = undefined
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** In-flight thread creations keyed by tab id (double-mount guard: React
|
|
91
|
+
* StrictMode / HMR must not mint two threads for one tab). */
|
|
92
|
+
const inFlightStarts = new Set<string>()
|
|
93
|
+
|
|
94
|
+
/** Per-thread transcript cache: seed boundary + thread-own events merged by
|
|
95
|
+
* seq (streaming polls never re-download the inherited seed). */
|
|
96
|
+
interface ThreadCache {
|
|
97
|
+
seedBoundary: number | null
|
|
98
|
+
entries: SidebarHistoryEntry[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Row-render labels (locale-dependent, memoized once per mount). */
|
|
102
|
+
interface RowLabels {
|
|
103
|
+
copyLabel: string
|
|
104
|
+
copiedLabel: string
|
|
105
|
+
thinkLabel: string
|
|
106
|
+
injectionLabel: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Merge history entries by event seq (newest wins), log order preserved. */
|
|
110
|
+
function mergeBySeq(
|
|
111
|
+
previous: readonly SidebarHistoryEntry[],
|
|
112
|
+
incoming: readonly SidebarHistoryEntry[],
|
|
113
|
+
): SidebarHistoryEntry[] {
|
|
114
|
+
const bySeq = new Map<number, SidebarHistoryEntry>()
|
|
115
|
+
for (const entry of previous) bySeq.set(entry.event.seq, entry)
|
|
116
|
+
for (const entry of incoming) bySeq.set(entry.event.seq, entry)
|
|
117
|
+
return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The display title of a thread: the durable label minus the 'Side: '
|
|
121
|
+
* prefix, with the fresh-thread placeholder localized. */
|
|
122
|
+
function threadDisplayTitle(title: string): string {
|
|
123
|
+
if (title === SIDE_NEW_THREAD_TITLE) return t('sideChatUntitled')
|
|
124
|
+
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One collapsible context row — the shared Codex-style chrome of tool
|
|
129
|
+
* calls, thinking and context injections: a single quiet line (chevron +
|
|
130
|
+
* label + one-line summary) that expands into an indented body hung on a
|
|
131
|
+
* hairline thread. Rows with nothing to reveal render as a static line.
|
|
132
|
+
*/
|
|
133
|
+
function CollapsibleRow(props: {
|
|
134
|
+
label: string
|
|
135
|
+
meta?: string
|
|
136
|
+
mono?: boolean
|
|
137
|
+
streaming?: boolean
|
|
138
|
+
failed?: boolean
|
|
139
|
+
children?: React.ReactNode
|
|
140
|
+
}): React.ReactNode {
|
|
141
|
+
const label = (
|
|
142
|
+
<span
|
|
143
|
+
className={clsx(
|
|
144
|
+
css.sidechatRowLabel,
|
|
145
|
+
props.mono === true && css.sidechatRowMono,
|
|
146
|
+
props.streaming === true && css.sidechatShimmerText,
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
{props.label}
|
|
150
|
+
</span>
|
|
151
|
+
)
|
|
152
|
+
const meta = props.meta !== undefined && props.meta !== ''
|
|
153
|
+
? <span className={css.sidechatRowMeta}>{props.meta}</span>
|
|
154
|
+
: null
|
|
155
|
+
if (props.children === undefined) {
|
|
156
|
+
return (
|
|
157
|
+
<div className={clsx(css.sidechatRowLine, css.sidechatRowStatic, props.failed === true && css.sidechatRowFailed)}>
|
|
158
|
+
{label}
|
|
159
|
+
{meta}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
return (
|
|
164
|
+
<details className={css.sidechatRow}>
|
|
165
|
+
<summary
|
|
166
|
+
className={clsx(
|
|
167
|
+
css.sidechatRowLine,
|
|
168
|
+
css.sidechatRowSummary,
|
|
169
|
+
props.failed === true && css.sidechatRowFailed,
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
<span className={css.sidechatRowChevron}>
|
|
173
|
+
<IconChevronRightOutline14 size={12} />
|
|
174
|
+
</span>
|
|
175
|
+
{label}
|
|
176
|
+
{meta}
|
|
177
|
+
</summary>
|
|
178
|
+
<div className={css.sidechatRowBody}>{props.children}</div>
|
|
179
|
+
</details>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** One row renderer (React keys ride the source event seq). */
|
|
184
|
+
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
|
|
185
|
+
switch (row.kind) {
|
|
186
|
+
case 'user':
|
|
187
|
+
return (
|
|
188
|
+
<div key={`${row.kind}:${row.seq}`} className={css.sidechatUser}>
|
|
189
|
+
<MarkdownText text={row.text} codeLabels={labels} />
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
case 'assistant':
|
|
193
|
+
return (
|
|
194
|
+
<div key={`${row.kind}:${row.seq}`} className={css.sidechatAssistant}>
|
|
195
|
+
<MarkdownText text={row.text} codeLabels={labels} />
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
case 'reasoning':
|
|
199
|
+
return (
|
|
200
|
+
<CollapsibleRow
|
|
201
|
+
key={`${row.kind}:${row.seq}`}
|
|
202
|
+
label={labels.thinkLabel}
|
|
203
|
+
streaming={!row.settled}
|
|
204
|
+
>
|
|
205
|
+
<div className={css.sidechatRowProse}>{row.text}</div>
|
|
206
|
+
</CollapsibleRow>
|
|
207
|
+
)
|
|
208
|
+
case 'injection':
|
|
209
|
+
return (
|
|
210
|
+
<CollapsibleRow key={`${row.kind}:${row.seq}`} label={labels.injectionLabel}>
|
|
211
|
+
<div className={css.sidechatRowProse}>{row.text}</div>
|
|
212
|
+
</CollapsibleRow>
|
|
213
|
+
)
|
|
214
|
+
case 'tool': {
|
|
215
|
+
const body = (
|
|
216
|
+
<>
|
|
217
|
+
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
|
|
218
|
+
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
|
|
219
|
+
</>
|
|
220
|
+
)
|
|
221
|
+
return (
|
|
222
|
+
<CollapsibleRow
|
|
223
|
+
key={`${row.kind}:${row.seq}`}
|
|
224
|
+
label={row.name}
|
|
225
|
+
meta={toolArgsSummary(row.args)}
|
|
226
|
+
mono
|
|
227
|
+
streaming={row.executing === true}
|
|
228
|
+
failed={row.failed}
|
|
229
|
+
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
|
|
230
|
+
/>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One side conversation tab (one thread per tab, Codex-style). */
|
|
237
|
+
export function SideChatView(props: {
|
|
238
|
+
ctx: Context
|
|
239
|
+
scope: SessionScope
|
|
240
|
+
tab: SidebarTab
|
|
241
|
+
visible: boolean
|
|
242
|
+
}): React.ReactNode {
|
|
243
|
+
const { ctx, scope, tab, visible } = props
|
|
244
|
+
const rowLabels = useMemo<RowLabels>(
|
|
245
|
+
() => ({
|
|
246
|
+
copyLabel: t('copy'),
|
|
247
|
+
copiedLabel: t('copied'),
|
|
248
|
+
thinkLabel: t('sideChatThink'),
|
|
249
|
+
injectionLabel: t('sideChatInjection'),
|
|
250
|
+
}),
|
|
251
|
+
[],
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
// The session list feed: thread rows (the header menu) + running states.
|
|
255
|
+
const list = useSyncExternalStore(
|
|
256
|
+
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
|
257
|
+
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
|
|
258
|
+
)
|
|
259
|
+
const threads = useMemo(
|
|
260
|
+
() => sideThreadRows(list.byId, scope.sessionId),
|
|
261
|
+
[list, scope.sessionId],
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
// The thread this tab is bound to rides tab.meta (refresh-restored).
|
|
265
|
+
const threadId = sidechatThreadIdOf(tab)
|
|
266
|
+
const autoCreate = (tab.meta as { autoCreate?: unknown } | undefined)?.autoCreate === true
|
|
267
|
+
|
|
268
|
+
const [composer, setComposer] = useState('')
|
|
269
|
+
const [busy, setBusy] = useState<'starting' | 'sending' | 'saving' | null>(null)
|
|
270
|
+
const [error, setError] = useState<string | null>(null)
|
|
271
|
+
const [saved, setSaved] = useState(false)
|
|
272
|
+
const [revision, setRevision] = useState(0)
|
|
273
|
+
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
|
|
274
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
275
|
+
|
|
276
|
+
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
|
|
277
|
+
const controllerRef = useRef<AbortController | null>(null)
|
|
278
|
+
const scrollRef = useRef<HTMLDivElement | null>(null)
|
|
279
|
+
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
|
280
|
+
|
|
281
|
+
const summary = threadId === undefined ? undefined : list.byId[threadId]
|
|
282
|
+
const running = summary?.running === true
|
|
283
|
+
|
|
284
|
+
/** The agent-identity badge of the thread header (preset · model). */
|
|
285
|
+
const agentBadge = useMemo(() => {
|
|
286
|
+
if (info === null) return ''
|
|
287
|
+
return [info.preset, info.model ?? info.provider].filter(Boolean).join(' · ')
|
|
288
|
+
}, [info])
|
|
289
|
+
|
|
290
|
+
/** Create this tab's thread (immediate-create tabs and hero retries). */
|
|
291
|
+
const startThread = useCallback(async (): Promise<void> => {
|
|
292
|
+
if (inFlightStarts.has(tab.id)) return
|
|
293
|
+
inFlightStarts.add(tab.id)
|
|
294
|
+
setBusy('starting')
|
|
295
|
+
setError(null)
|
|
296
|
+
try {
|
|
297
|
+
const { childId } = await api.sidechatStart(scope.sessionId)
|
|
298
|
+
ctx.betterSidebar?.updateTab(tab.id, { meta: { threadId: childId } })
|
|
299
|
+
} catch (cause) {
|
|
300
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
301
|
+
} finally {
|
|
302
|
+
inFlightStarts.delete(tab.id)
|
|
303
|
+
setBusy(null)
|
|
304
|
+
}
|
|
305
|
+
}, [ctx, scope.sessionId, tab.id])
|
|
306
|
+
|
|
307
|
+
// Codex-style immediate create: an autoCreate tab spawns its thread as
|
|
308
|
+
// soon as it first renders.
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (threadId !== undefined || !autoCreate || !visible) return
|
|
311
|
+
void startThread()
|
|
312
|
+
}, [threadId, autoCreate, visible, startThread])
|
|
313
|
+
|
|
314
|
+
// The tab title follows the thread's durable label (the first prompt
|
|
315
|
+
// renames the thread; the strip picks it up here).
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
const display = summary?.displayTitle
|
|
318
|
+
if (display === undefined) return
|
|
319
|
+
const title = threadDisplayTitle(display)
|
|
320
|
+
if (title !== '' && title !== tab.title) {
|
|
321
|
+
try {
|
|
322
|
+
ctx.betterSidebar?.updateTab(tab.id, { title })
|
|
323
|
+
} catch {
|
|
324
|
+
// A stale title is cosmetic; the thread keeps working.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}, [summary, tab.id, tab.title, ctx])
|
|
328
|
+
|
|
329
|
+
/** One transcript pull: the first read walks back to the seed boundary
|
|
330
|
+
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
|
|
331
|
+
* one tail page and merge (seq-deduped). */
|
|
332
|
+
const fetchThread = useCallback(async (childId: string): Promise<void> => {
|
|
333
|
+
controllerRef.current?.abort()
|
|
334
|
+
const controller = new AbortController()
|
|
335
|
+
controllerRef.current = controller
|
|
336
|
+
const cache = cacheRef.current
|
|
337
|
+
try {
|
|
338
|
+
if (cache.seedBoundary === null) {
|
|
339
|
+
const walk = await collectOwnEvents(async (beforeSeq) => {
|
|
340
|
+
const response = await ctx.connection.api.sessions.history(
|
|
341
|
+
{
|
|
342
|
+
sessionId: childId,
|
|
343
|
+
maxMessages: WALK_PAGE_EVENTS,
|
|
344
|
+
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
|
345
|
+
},
|
|
346
|
+
controller.signal,
|
|
347
|
+
)
|
|
348
|
+
if (!response.result.ok) throw new Error('history walk failed')
|
|
349
|
+
return response.result.value.events
|
|
350
|
+
})
|
|
351
|
+
cache.seedBoundary = walk.seedBoundary
|
|
352
|
+
cache.entries = mergeBySeq(cache.entries, walk.entries)
|
|
353
|
+
} else {
|
|
354
|
+
const response = await ctx.connection.api.sessions.history(
|
|
355
|
+
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
|
|
356
|
+
controller.signal,
|
|
357
|
+
)
|
|
358
|
+
if (!response.result.ok) return
|
|
359
|
+
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
|
|
360
|
+
}
|
|
361
|
+
setRevision(value => value + 1)
|
|
362
|
+
} catch {
|
|
363
|
+
// Aborted by a newer pull or a wire failure: keep the last rows.
|
|
364
|
+
}
|
|
365
|
+
}, [ctx])
|
|
366
|
+
|
|
367
|
+
/** The thread header badge pull (live state + preset/model identity). */
|
|
368
|
+
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
|
|
369
|
+
try {
|
|
370
|
+
setInfo(await api.sidechatInfo(childId))
|
|
371
|
+
} catch {
|
|
372
|
+
// The badge is decorative; a wire failure keeps the last value.
|
|
373
|
+
}
|
|
374
|
+
}, [])
|
|
375
|
+
|
|
376
|
+
// Reset the transcript cache whenever the binding changes, then focus
|
|
377
|
+
// the composer — it owns the first message of a fresh thread.
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
cacheRef.current = { seedBoundary: null, entries: [] }
|
|
380
|
+
controllerRef.current?.abort()
|
|
381
|
+
setError(null)
|
|
382
|
+
setSaved(false)
|
|
383
|
+
setInfo(null)
|
|
384
|
+
if (threadId !== undefined) {
|
|
385
|
+
void fetchInfo(threadId)
|
|
386
|
+
window.setTimeout(() => composerRef.current?.focus(), 0)
|
|
387
|
+
}
|
|
388
|
+
}, [threadId, fetchInfo])
|
|
389
|
+
|
|
390
|
+
// Poll while the tab is visible and the thread runs.
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
if (!visible || threadId === undefined) return
|
|
393
|
+
void fetchThread(threadId)
|
|
394
|
+
if (!running) return
|
|
395
|
+
const timer = window.setInterval(() => {
|
|
396
|
+
void fetchThread(threadId)
|
|
397
|
+
void fetchInfo(threadId)
|
|
398
|
+
}, POLL_MS)
|
|
399
|
+
return () => { window.clearInterval(timer) }
|
|
400
|
+
}, [visible, threadId, running, fetchThread, fetchInfo])
|
|
401
|
+
|
|
402
|
+
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
|
403
|
+
|
|
404
|
+
const rows = useMemo(
|
|
405
|
+
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
|
|
406
|
+
// The cache is a ref; revision bumps on every successful pull.
|
|
407
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
408
|
+
[threadId, revision],
|
|
409
|
+
)
|
|
410
|
+
const canSave = threadId !== undefined && threadHasCompletedTurn(cacheRef.current.entries)
|
|
411
|
+
const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries)
|
|
412
|
+
const freshThread = threadId !== undefined && rows.length === 0
|
|
413
|
+
|
|
414
|
+
// Follow the stream: stick to the bottom while the log grows.
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
const scroller = scrollRef.current
|
|
417
|
+
if (scroller === null) return
|
|
418
|
+
scroller.scrollTop = scroller.scrollHeight
|
|
419
|
+
}, [rows.length, threadId])
|
|
420
|
+
|
|
421
|
+
/** Open a NEW thread tab (createTab mints the autoCreate tab; its view
|
|
422
|
+
* creates the thread on mount). */
|
|
423
|
+
const openNewThread = (): void => {
|
|
424
|
+
setMenuOpen(false)
|
|
425
|
+
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Switch to an existing thread: parked for createTab, deduped to the
|
|
429
|
+
* already-open tab when there is one. */
|
|
430
|
+
const openExistingThread = (id: string): void => {
|
|
431
|
+
setMenuOpen(false)
|
|
432
|
+
if (id === threadId) return
|
|
433
|
+
parkSidechatReopen(id)
|
|
434
|
+
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const menuItems = useMemo<MenuEntry[]>(() => {
|
|
438
|
+
const items: MenuEntry[] = [
|
|
439
|
+
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
|
|
440
|
+
]
|
|
441
|
+
if (threads.length > 0) {
|
|
442
|
+
items.push({ type: 'separator', id: '$sep' })
|
|
443
|
+
for (const row of threads) {
|
|
444
|
+
items.push({
|
|
445
|
+
id: row.id,
|
|
446
|
+
label: threadDisplayTitle(row.title),
|
|
447
|
+
...(row.running ? { icon: <StateDot state="ongoing" size={8} /> } : {}),
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return items
|
|
452
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
453
|
+
}, [threads])
|
|
454
|
+
|
|
455
|
+
const growComposer = (): void => {
|
|
456
|
+
const field = composerRef.current
|
|
457
|
+
if (field === null) return
|
|
458
|
+
field.style.height = '0px'
|
|
459
|
+
field.style.height = `${Math.min(field.scrollHeight, COMPOSER_MAX_HEIGHT)}px`
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const handleSend = async (): Promise<void> => {
|
|
463
|
+
const text = composer.trim()
|
|
464
|
+
if (text === '' || threadId === undefined || busy !== null) return
|
|
465
|
+
setBusy('sending')
|
|
466
|
+
setError(null)
|
|
467
|
+
try {
|
|
468
|
+
await api.sidechatPrompt(threadId, text)
|
|
469
|
+
setComposer('')
|
|
470
|
+
const field = composerRef.current
|
|
471
|
+
if (field !== null) field.style.height = ''
|
|
472
|
+
void fetchThread(threadId)
|
|
473
|
+
void fetchInfo(threadId)
|
|
474
|
+
} catch (cause) {
|
|
475
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
476
|
+
} finally {
|
|
477
|
+
setBusy(null)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const handleCancel = async (): Promise<void> => {
|
|
482
|
+
if (threadId === undefined || busy !== null) return
|
|
483
|
+
try {
|
|
484
|
+
await api.sidechatCancel(threadId)
|
|
485
|
+
} catch (cause) {
|
|
486
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const handleSave = async (): Promise<void> => {
|
|
491
|
+
if (threadId === undefined || !canSave || busy !== null) return
|
|
492
|
+
setBusy('saving')
|
|
493
|
+
setError(null)
|
|
494
|
+
setSaved(false)
|
|
495
|
+
try {
|
|
496
|
+
// NOTE: fork must stay a METHOD call — `ctx.sessions.fork` is the
|
|
497
|
+
// client-runtime sessions service, and an unbound reference loses
|
|
498
|
+
// `this` (its fork reads this.list for the title bump).
|
|
499
|
+
if (ctx.sessions.fork === undefined) throw new Error('session fork is unavailable')
|
|
500
|
+
const newId = await ctx.sessions.fork({ sessionId: threadId, increaseTitle: true })
|
|
501
|
+
const title = summary === undefined ? '' : threadDisplayTitle(summary.displayTitle).trim()
|
|
502
|
+
const binding = ctx.sessions.binding?.(newId)
|
|
503
|
+
if (binding !== undefined && title !== '') {
|
|
504
|
+
await binding.session.rename(title)
|
|
505
|
+
}
|
|
506
|
+
ctx.sessions.open?.(newId)
|
|
507
|
+
setSaved(true)
|
|
508
|
+
} catch (cause) {
|
|
509
|
+
setError(cause instanceof Error ? cause.message : String(cause))
|
|
510
|
+
} finally {
|
|
511
|
+
setBusy(null)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── unbound tab: the hero (fresh autoCreate tabs flash a creating state
|
|
516
|
+
// until the thread lands; legacy persisted tabs offer a manual start) ──
|
|
517
|
+
if (threadId === undefined) {
|
|
518
|
+
return (
|
|
519
|
+
<div className={css.sidechat}>
|
|
520
|
+
<div className={css.sidechatHero}>
|
|
521
|
+
<IconNewChatOutline16 />
|
|
522
|
+
<div
|
|
523
|
+
className={clsx(
|
|
524
|
+
css.sidechatHeroTitle,
|
|
525
|
+
busy === 'starting' && css.sidechatShimmerText,
|
|
526
|
+
)}
|
|
527
|
+
>
|
|
528
|
+
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
|
|
529
|
+
</div>
|
|
530
|
+
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
|
|
531
|
+
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
|
|
532
|
+
{busy !== 'starting' && (
|
|
533
|
+
<button
|
|
534
|
+
type="button"
|
|
535
|
+
className={css.sidechatPrimaryBtn}
|
|
536
|
+
onClick={() => void startThread()}
|
|
537
|
+
>
|
|
538
|
+
{error === null ? t('sideChatNew') : t('sideChatRetry')}
|
|
539
|
+
</button>
|
|
540
|
+
)}
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<div className={css.sidechat}>
|
|
548
|
+
<div className={css.sidechatDetailHeader}>
|
|
549
|
+
{running && <StateDot state="ongoing" size={8} className={css.sidechatHeaderDot} />}
|
|
550
|
+
{agentBadge !== '' && <span className={css.sidechatAgentBadge}>{agentBadge}</span>}
|
|
551
|
+
<span className={css.sidechatHeaderSpacer} />
|
|
552
|
+
<Menu
|
|
553
|
+
open={menuOpen}
|
|
554
|
+
anchor={(
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
className={css.sidechatIconBtn}
|
|
558
|
+
onClick={() => { setMenuOpen(value => !value) }}
|
|
559
|
+
title={t('sideChatThreads')}
|
|
560
|
+
>
|
|
561
|
+
<IconHistoryOutline16 />
|
|
562
|
+
</button>
|
|
563
|
+
)}
|
|
564
|
+
items={menuItems}
|
|
565
|
+
selectedId={threadId}
|
|
566
|
+
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
|
|
567
|
+
onClose={() => { setMenuOpen(false) }}
|
|
568
|
+
align="end"
|
|
569
|
+
portal
|
|
570
|
+
dense
|
|
571
|
+
/>
|
|
572
|
+
<button
|
|
573
|
+
type="button"
|
|
574
|
+
className={css.sidechatIconBtn}
|
|
575
|
+
onClick={() => void handleSave()}
|
|
576
|
+
disabled={!canSave || busy !== null}
|
|
577
|
+
title={`${t('sideChatSave')} — ${t('sideChatSaveTitle')}`}
|
|
578
|
+
>
|
|
579
|
+
<IconSaveOutline16 />
|
|
580
|
+
</button>
|
|
581
|
+
</div>
|
|
582
|
+
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
|
|
583
|
+
{canSave && trailingPending
|
|
584
|
+
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
|
|
585
|
+
{saved && <div className={css.sidechatHint}>{t('sideChatSaved')}</div>}
|
|
586
|
+
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
|
|
587
|
+
<div ref={scrollRef} className={css.sidechatScroll}>
|
|
588
|
+
{rows.map(row => renderRow(row, rowLabels))}
|
|
589
|
+
</div>
|
|
590
|
+
{running && (
|
|
591
|
+
<div className={css.sidechatStatus}>
|
|
592
|
+
<StateDot state="ongoing" size={8} />
|
|
593
|
+
<span className={css.sidechatStatusText}>{t('sideChatThinking')}</span>
|
|
594
|
+
</div>
|
|
595
|
+
)}
|
|
596
|
+
<div className={css.sidechatComposer}>
|
|
597
|
+
<textarea
|
|
598
|
+
ref={composerRef}
|
|
599
|
+
className={css.sidechatComposerInput}
|
|
600
|
+
value={composer}
|
|
601
|
+
placeholder={freshThread ? t('sideChatFirstPlaceholder') : t('sideChatComposerPlaceholder')}
|
|
602
|
+
rows={1}
|
|
603
|
+
onChange={event => {
|
|
604
|
+
setComposer(event.target.value)
|
|
605
|
+
growComposer()
|
|
606
|
+
}}
|
|
607
|
+
onKeyDown={event => {
|
|
608
|
+
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
|
|
609
|
+
event.preventDefault()
|
|
610
|
+
void handleSend()
|
|
611
|
+
}}
|
|
612
|
+
/>
|
|
613
|
+
<div className={css.sidechatComposerBar}>
|
|
614
|
+
<span className={css.sidechatComposerMeta}>
|
|
615
|
+
{running ? '' : agentBadge}
|
|
616
|
+
</span>
|
|
617
|
+
{running ? (
|
|
618
|
+
<button
|
|
619
|
+
key="stop"
|
|
620
|
+
type="button"
|
|
621
|
+
className={css.sidechatSendBtn}
|
|
622
|
+
onClick={() => void handleCancel()}
|
|
623
|
+
disabled={busy !== null}
|
|
624
|
+
title={t('sideChatCancelTitle')}
|
|
625
|
+
>
|
|
626
|
+
<IconStopFill16 />
|
|
627
|
+
</button>
|
|
628
|
+
) : (
|
|
629
|
+
<button
|
|
630
|
+
key="send"
|
|
631
|
+
type="button"
|
|
632
|
+
className={css.sidechatSendBtn}
|
|
633
|
+
onClick={() => void handleSend()}
|
|
634
|
+
disabled={composer.trim() === '' || busy !== null}
|
|
635
|
+
title={t('sideChatSend')}
|
|
636
|
+
>
|
|
637
|
+
<IconSendOutline16 />
|
|
638
|
+
</button>
|
|
639
|
+
)}
|
|
640
|
+
</div>
|
|
641
|
+
</div>
|
|
642
|
+
</div>
|
|
643
|
+
)
|
|
644
|
+
}
|