@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.
Files changed (220) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +0 -0
  3. package/README_EN.md +0 -0
  4. package/cordis.patch.yml +49 -0
  5. package/lib/client-editor.js +34651 -0
  6. package/lib/client-mermaid.js +200629 -0
  7. package/lib/client-registry.js +13388 -0
  8. package/lib/client-terminal.js +8904 -0
  9. package/lib/client.js +13388 -0
  10. package/lib/index.js +3959 -0
  11. package/lib/invariant.js +22 -0
  12. package/lib/types/agent-pty.d.ts +226 -0
  13. package/lib/types/browser-probe.d.ts +15 -0
  14. package/lib/types/bundle-route.d.ts +12 -0
  15. package/lib/types/client/BrowserView.d.ts +22 -0
  16. package/lib/types/client/DiffTab.d.ts +6 -0
  17. package/lib/types/client/DiffView.d.ts +51 -0
  18. package/lib/types/client/EditorHost.d.ts +12 -0
  19. package/lib/types/client/FileTree.d.ts +36 -0
  20. package/lib/types/client/GitView.d.ts +8 -0
  21. package/lib/types/client/OrphanedTab.d.ts +11 -0
  22. package/lib/types/client/PdfView.d.ts +6 -0
  23. package/lib/types/client/RenderBoundary.d.ts +32 -0
  24. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  25. package/lib/types/client/SideCardSection.d.ts +74 -0
  26. package/lib/types/client/SideChatView.d.ts +16 -0
  27. package/lib/types/client/Sidebar.d.ts +6 -0
  28. package/lib/types/client/SubagentView.d.ts +16 -0
  29. package/lib/types/client/TabBar.d.ts +43 -0
  30. package/lib/types/client/TerminalView.d.ts +24 -0
  31. package/lib/types/client/TextEditor.d.ts +10 -0
  32. package/lib/types/client/TreePanel.d.ts +23 -0
  33. package/lib/types/client/UploadOverlay.d.ts +20 -0
  34. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +268 -0
  36. package/lib/types/client/binary-download.d.ts +12 -0
  37. package/lib/types/client/breakpoints.d.ts +12 -0
  38. package/lib/types/client/browser.d.ts +50 -0
  39. package/lib/types/client/builtins/index.d.ts +18 -0
  40. package/lib/types/client/builtins/tabs.d.ts +11 -0
  41. package/lib/types/client/builtins/viewers.d.ts +3 -0
  42. package/lib/types/client/chunk-loader.d.ts +124 -0
  43. package/lib/types/client/chunks/editor.d.ts +9 -0
  44. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  45. package/lib/types/client/chunks/terminal.d.ts +9 -0
  46. package/lib/types/client/cm-themes.d.ts +24 -0
  47. package/lib/types/client/conversation-draft.d.ts +14 -0
  48. package/lib/types/client/desktop-env.d.ts +35 -0
  49. package/lib/types/client/editable-state.d.ts +27 -0
  50. package/lib/types/client/editor-load.d.ts +66 -0
  51. package/lib/types/client/frame-batcher.d.ts +24 -0
  52. package/lib/types/client/icons.d.ts +57 -0
  53. package/lib/types/client/image-types.d.ts +3 -0
  54. package/lib/types/client/ime-guard.d.ts +36 -0
  55. package/lib/types/client/index.d.ts +19 -0
  56. package/lib/types/client/intercept.d.ts +33 -0
  57. package/lib/types/client/lang.d.ts +12 -0
  58. package/lib/types/client/lazy-chunk.d.ts +25 -0
  59. package/lib/types/client/link-intercept.d.ts +42 -0
  60. package/lib/types/client/locales.d.ts +366 -0
  61. package/lib/types/client/mermaid-blocks.d.ts +41 -0
  62. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  63. package/lib/types/client/mermaid.d.ts +12 -0
  64. package/lib/types/client/open-when-sized.d.ts +20 -0
  65. package/lib/types/client/open-with-settings.d.ts +4 -0
  66. package/lib/types/client/open-with.d.ts +87 -0
  67. package/lib/types/client/openpath-intercept.d.ts +41 -0
  68. package/lib/types/client/paths.d.ts +35 -0
  69. package/lib/types/client/pdf-types.d.ts +2 -0
  70. package/lib/types/client/plugin-settings.d.ts +8 -0
  71. package/lib/types/client/plugins-shared.d.ts +27 -0
  72. package/lib/types/client/plugins-tabs.d.ts +3 -0
  73. package/lib/types/client/plugins-viewers.d.ts +3 -0
  74. package/lib/types/client/prefs.d.ts +41 -0
  75. package/lib/types/client/produced-files.d.ts +23 -0
  76. package/lib/types/client/selection-payload.d.ts +27 -0
  77. package/lib/types/client/service.d.ts +440 -0
  78. package/lib/types/client/settings-nav-icon.d.ts +19 -0
  79. package/lib/types/client/shell-presets.d.ts +48 -0
  80. package/lib/types/client/sidechat-transcript.d.ts +103 -0
  81. package/lib/types/client/split-pane.d.ts +30 -0
  82. package/lib/types/client/state.d.ts +359 -0
  83. package/lib/types/client/subagent-detect.d.ts +54 -0
  84. package/lib/types/client/subagent-jobs.d.ts +63 -0
  85. package/lib/types/client/tab-content-memo.d.ts +33 -0
  86. package/lib/types/client/terminal-font.d.ts +20 -0
  87. package/lib/types/client/theme.d.ts +45 -0
  88. package/lib/types/client/titlebar-strip.d.ts +23 -0
  89. package/lib/types/client/upload.d.ts +69 -0
  90. package/lib/types/client/wco.d.ts +47 -0
  91. package/lib/types/config.d.ts +64 -0
  92. package/lib/types/context-types.d.ts +603 -0
  93. package/lib/types/fs-operations.d.ts +28 -0
  94. package/lib/types/fs-search.d.ts +24 -0
  95. package/lib/types/fs-tree.d.ts +51 -0
  96. package/lib/types/git.d.ts +82 -0
  97. package/lib/types/html-route.d.ts +59 -0
  98. package/lib/types/index.d.ts +47 -0
  99. package/lib/types/invariant.d.ts +15 -0
  100. package/lib/types/jobs-routes.d.ts +45 -0
  101. package/lib/types/open-external.d.ts +24 -0
  102. package/lib/types/prefs-shared.d.ts +225 -0
  103. package/lib/types/pty-deps.d.ts +78 -0
  104. package/lib/types/pty-manager.d.ts +168 -0
  105. package/lib/types/sidechat-core.d.ts +172 -0
  106. package/lib/types/sidechat-routes.d.ts +30 -0
  107. package/lib/types/subagent-activity.d.ts +44 -0
  108. package/lib/types/subagent-live-route.d.ts +44 -0
  109. package/lib/types/tools.d.ts +31 -0
  110. package/lib/types/trust-fence.d.ts +24 -0
  111. package/lib/types/wire.d.ts +38 -0
  112. package/package.json +173 -0
  113. package/scripts/install.ps1 +289 -0
  114. package/scripts/install.sh +287 -0
  115. package/src/agent-pty.ts +527 -0
  116. package/src/browser-probe.ts +26 -0
  117. package/src/bundle-route.ts +129 -0
  118. package/src/client/BrowserView.tsx +253 -0
  119. package/src/client/DiffTab.tsx +110 -0
  120. package/src/client/DiffView.tsx +305 -0
  121. package/src/client/EditorHost.tsx +525 -0
  122. package/src/client/FileTree.tsx +653 -0
  123. package/src/client/GitView.tsx +558 -0
  124. package/src/client/OrphanedTab.tsx +27 -0
  125. package/src/client/PdfView.tsx +110 -0
  126. package/src/client/RenderBoundary.tsx +49 -0
  127. package/src/client/SandboxStatusBar.tsx +60 -0
  128. package/src/client/SideCardSection.module.css +960 -0
  129. package/src/client/SideCardSection.tsx +1152 -0
  130. package/src/client/SideChatView.module.css +437 -0
  131. package/src/client/SideChatView.tsx +644 -0
  132. package/src/client/Sidebar.tsx +1148 -0
  133. package/src/client/SubagentView.module.css +547 -0
  134. package/src/client/SubagentView.tsx +883 -0
  135. package/src/client/TabBar.tsx +305 -0
  136. package/src/client/TerminalView.tsx +383 -0
  137. package/src/client/TextEditor.tsx +500 -0
  138. package/src/client/TreePanel.tsx +273 -0
  139. package/src/client/UploadOverlay.tsx +62 -0
  140. package/src/client/add-plugin-modal.tsx +199 -0
  141. package/src/client/api.ts +335 -0
  142. package/src/client/binary-download.tsx +23 -0
  143. package/src/client/breakpoints.ts +51 -0
  144. package/src/client/browser.ts +119 -0
  145. package/src/client/builtins/index.ts +37 -0
  146. package/src/client/builtins/tabs.tsx +331 -0
  147. package/src/client/builtins/viewers.tsx +125 -0
  148. package/src/client/chunk-loader.ts +351 -0
  149. package/src/client/chunks/editor.tsx +9 -0
  150. package/src/client/chunks/mermaid.tsx +10 -0
  151. package/src/client/chunks/terminal.tsx +9 -0
  152. package/src/client/cm-themes.ts +129 -0
  153. package/src/client/conversation-draft.ts +29 -0
  154. package/src/client/css-modules.d.ts +5 -0
  155. package/src/client/desktop-env.ts +72 -0
  156. package/src/client/editable-state.ts +70 -0
  157. package/src/client/editor-load.ts +92 -0
  158. package/src/client/frame-batcher.ts +56 -0
  159. package/src/client/icons.tsx +161 -0
  160. package/src/client/image-types.ts +8 -0
  161. package/src/client/ime-guard.ts +47 -0
  162. package/src/client/index.tsx +369 -0
  163. package/src/client/intercept.tsx +110 -0
  164. package/src/client/lang.ts +103 -0
  165. package/src/client/layout.css +94 -0
  166. package/src/client/lazy-chunk.tsx +89 -0
  167. package/src/client/link-intercept.ts +73 -0
  168. package/src/client/locales.ts +734 -0
  169. package/src/client/mermaid-blocks.ts +110 -0
  170. package/src/client/mermaid-sanitize.ts +93 -0
  171. package/src/client/mermaid.tsx +401 -0
  172. package/src/client/open-when-sized.ts +43 -0
  173. package/src/client/open-with-settings.tsx +135 -0
  174. package/src/client/open-with.ts +221 -0
  175. package/src/client/openpath-intercept.ts +62 -0
  176. package/src/client/paths.ts +47 -0
  177. package/src/client/pdf-types.ts +4 -0
  178. package/src/client/plugin-settings.ts +43 -0
  179. package/src/client/plugins-shared.ts +29 -0
  180. package/src/client/plugins-tabs.ts +87 -0
  181. package/src/client/plugins-viewers.ts +30 -0
  182. package/src/client/prefs.ts +207 -0
  183. package/src/client/produced-files.ts +91 -0
  184. package/src/client/selection-payload.ts +86 -0
  185. package/src/client/service.ts +838 -0
  186. package/src/client/settings-nav-icon.ts +45 -0
  187. package/src/client/shell-presets.ts +83 -0
  188. package/src/client/sidebar.module.css +2577 -0
  189. package/src/client/sidechat-transcript.ts +321 -0
  190. package/src/client/split-pane.tsx +322 -0
  191. package/src/client/state.ts +1175 -0
  192. package/src/client/subagent-detect.ts +137 -0
  193. package/src/client/subagent-jobs.ts +161 -0
  194. package/src/client/tab-content-memo.ts +46 -0
  195. package/src/client/terminal-font.ts +28 -0
  196. package/src/client/theme.ts +110 -0
  197. package/src/client/titlebar-strip.ts +37 -0
  198. package/src/client/upload.ts +187 -0
  199. package/src/client/wco.ts +120 -0
  200. package/src/config.ts +155 -0
  201. package/src/context-types.ts +610 -0
  202. package/src/fs-operations.ts +99 -0
  203. package/src/fs-search.ts +86 -0
  204. package/src/fs-tree.ts +158 -0
  205. package/src/git.ts +244 -0
  206. package/src/html-route.ts +106 -0
  207. package/src/index.ts +1132 -0
  208. package/src/invariant.ts +32 -0
  209. package/src/jobs-routes.ts +257 -0
  210. package/src/open-external.ts +90 -0
  211. package/src/prefs-shared.ts +268 -0
  212. package/src/pty-deps.ts +240 -0
  213. package/src/pty-manager.ts +376 -0
  214. package/src/sidechat-core.ts +468 -0
  215. package/src/sidechat-routes.ts +344 -0
  216. package/src/subagent-activity.ts +91 -0
  217. package/src/subagent-live-route.ts +93 -0
  218. package/src/tools.ts +479 -0
  219. package/src/trust-fence.ts +77 -0
  220. 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
+ }