dsh-coding-sidebar 1.0.0

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