@chatcode/cco-better-sidebar 0.18.0-alpha.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 (329) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/README_EN.md +563 -0
  4. package/assets/logo.svg +1 -0
  5. package/cordis.patch.yml +79 -0
  6. package/lib/client-editor.js +55744 -0
  7. package/lib/client-mermaid.js +208256 -0
  8. package/lib/client-registry.js +30378 -0
  9. package/lib/client-terminal.js +16894 -0
  10. package/lib/client.js +30378 -0
  11. package/lib/index.js +6897 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-opens.d.ts +65 -0
  14. package/lib/types/agent-pty.d.ts +226 -0
  15. package/lib/types/browser-probe.d.ts +15 -0
  16. package/lib/types/bundle-route.d.ts +12 -0
  17. package/lib/types/chatcode-update.d.ts +47 -0
  18. package/lib/types/client/AccountMenu.d.ts +1 -0
  19. package/lib/types/client/AiRatioSection.d.ts +3 -0
  20. package/lib/types/client/BrowserView.d.ts +39 -0
  21. package/lib/types/client/ChatcodeGate.d.ts +6 -0
  22. package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -0
  23. package/lib/types/client/DiffTab.d.ts +6 -0
  24. package/lib/types/client/DiffView.d.ts +51 -0
  25. package/lib/types/client/EditorHost.d.ts +13 -0
  26. package/lib/types/client/FenceErrorNotice.d.ts +5 -0
  27. package/lib/types/client/FileTree.d.ts +41 -0
  28. package/lib/types/client/FreeWindow.d.ts +29 -0
  29. package/lib/types/client/GitView.d.ts +12 -0
  30. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  31. package/lib/types/client/OrphanedTab.d.ts +11 -0
  32. package/lib/types/client/PdfView.d.ts +6 -0
  33. package/lib/types/client/ReleaseNotesDialog.d.ts +11 -0
  34. package/lib/types/client/RenderBoundary.d.ts +32 -0
  35. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  36. package/lib/types/client/SideCardSection.d.ts +74 -0
  37. package/lib/types/client/Sidebar.d.ts +8 -0
  38. package/lib/types/client/TabBar.d.ts +54 -0
  39. package/lib/types/client/TerminalView.d.ts +24 -0
  40. package/lib/types/client/TextEditor.d.ts +10 -0
  41. package/lib/types/client/TreePanel.d.ts +27 -0
  42. package/lib/types/client/UpdateDialog.d.ts +10 -0
  43. package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
  44. package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
  45. package/lib/types/client/UploadOverlay.d.ts +20 -0
  46. package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
  47. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  48. package/lib/types/client/api.d.ts +345 -0
  49. package/lib/types/client/binary-download.d.ts +12 -0
  50. package/lib/types/client/breakpoints.d.ts +17 -0
  51. package/lib/types/client/browser.d.ts +60 -0
  52. package/lib/types/client/builtins/index.d.ts +18 -0
  53. package/lib/types/client/builtins/tabs.d.ts +11 -0
  54. package/lib/types/client/builtins/viewers.d.ts +3 -0
  55. package/lib/types/client/chat-presentation.d.ts +13 -0
  56. package/lib/types/client/chunk-loader.d.ts +124 -0
  57. package/lib/types/client/chunks/editor.d.ts +9 -0
  58. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  59. package/lib/types/client/chunks/terminal.d.ts +9 -0
  60. package/lib/types/client/cm-themes.d.ts +24 -0
  61. package/lib/types/client/conversation-draft.d.ts +14 -0
  62. package/lib/types/client/desktop-env.d.ts +35 -0
  63. package/lib/types/client/editor-load.d.ts +66 -0
  64. package/lib/types/client/frame-batcher.d.ts +24 -0
  65. package/lib/types/client/icons.d.ts +66 -0
  66. package/lib/types/client/image-types.d.ts +3 -0
  67. package/lib/types/client/ime-guard.d.ts +36 -0
  68. package/lib/types/client/index.d.ts +19 -0
  69. package/lib/types/client/intercept.d.ts +44 -0
  70. package/lib/types/client/lang.d.ts +14 -0
  71. package/lib/types/client/layout-push.d.ts +15 -0
  72. package/lib/types/client/lazy-chunk.d.ts +25 -0
  73. package/lib/types/client/link-intercept.d.ts +42 -0
  74. package/lib/types/client/locales-ar.d.ts +18 -0
  75. package/lib/types/client/locales-de.d.ts +4 -0
  76. package/lib/types/client/locales-fr.d.ts +10 -0
  77. package/lib/types/client/locales-hi.d.ts +17 -0
  78. package/lib/types/client/locales-id.d.ts +15 -0
  79. package/lib/types/client/locales-it.d.ts +8 -0
  80. package/lib/types/client/locales-ja.d.ts +17 -0
  81. package/lib/types/client/locales-ko.d.ts +9 -0
  82. package/lib/types/client/locales-nl.d.ts +16 -0
  83. package/lib/types/client/locales-pl.d.ts +19 -0
  84. package/lib/types/client/locales-pt.d.ts +1 -0
  85. package/lib/types/client/locales-ru.d.ts +13 -0
  86. package/lib/types/client/locales-sv.d.ts +1 -0
  87. package/lib/types/client/locales-th.d.ts +17 -0
  88. package/lib/types/client/locales-tr.d.ts +17 -0
  89. package/lib/types/client/locales-vi.d.ts +17 -0
  90. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  91. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  92. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  93. package/lib/types/client/locales.d.ts +571 -0
  94. package/lib/types/client/markdown-html.d.ts +96 -0
  95. package/lib/types/client/markdown-images.d.ts +45 -0
  96. package/lib/types/client/markdown-labels.d.ts +21 -0
  97. package/lib/types/client/md-toc.d.ts +4 -0
  98. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  99. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  100. package/lib/types/client/mermaid.d.ts +12 -0
  101. package/lib/types/client/open-when-sized.d.ts +20 -0
  102. package/lib/types/client/open-with-settings.d.ts +4 -0
  103. package/lib/types/client/open-with.d.ts +87 -0
  104. package/lib/types/client/openpath-intercept.d.ts +52 -0
  105. package/lib/types/client/paths.d.ts +43 -0
  106. package/lib/types/client/pdf-types.d.ts +2 -0
  107. package/lib/types/client/pinned.d.ts +86 -0
  108. package/lib/types/client/plugin-settings.d.ts +8 -0
  109. package/lib/types/client/plugins-shared.d.ts +27 -0
  110. package/lib/types/client/plugins-tabs.d.ts +3 -0
  111. package/lib/types/client/plugins-viewers.d.ts +3 -0
  112. package/lib/types/client/prefs.d.ts +41 -0
  113. package/lib/types/client/produced-files.d.ts +29 -0
  114. package/lib/types/client/selection-payload.d.ts +27 -0
  115. package/lib/types/client/service.d.ts +445 -0
  116. package/lib/types/client/session-pins.d.ts +14 -0
  117. package/lib/types/client/settings-nav-icon.d.ts +27 -0
  118. package/lib/types/client/shell-presets.d.ts +48 -0
  119. package/lib/types/client/split-pane.d.ts +38 -0
  120. package/lib/types/client/state.d.ts +480 -0
  121. package/lib/types/client/tab-content-memo.d.ts +40 -0
  122. package/lib/types/client/terminal-font.d.ts +78 -0
  123. package/lib/types/client/terminal-links.d.ts +119 -0
  124. package/lib/types/client/theme.d.ts +45 -0
  125. package/lib/types/client/titlebar-strip.d.ts +23 -0
  126. package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
  127. package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
  128. package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
  129. package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
  130. package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
  131. package/lib/types/client/ui-tweaks/index.d.ts +279 -0
  132. package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
  133. package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
  134. package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
  135. package/lib/types/client/upload.d.ts +69 -0
  136. package/lib/types/client/useChatcodeAccount.d.ts +10 -0
  137. package/lib/types/client/wco.d.ts +47 -0
  138. package/lib/types/codingplan.d.ts +37 -0
  139. package/lib/types/config.d.ts +95 -0
  140. package/lib/types/context-types.d.ts +540 -0
  141. package/lib/types/fs-operations.d.ts +30 -0
  142. package/lib/types/fs-search.d.ts +24 -0
  143. package/lib/types/fs-tree.d.ts +51 -0
  144. package/lib/types/git.d.ts +124 -0
  145. package/lib/types/html-route.d.ts +59 -0
  146. package/lib/types/index.d.ts +47 -0
  147. package/lib/types/invariant.d.ts +15 -0
  148. package/lib/types/login-route.d.ts +2 -0
  149. package/lib/types/open-external.d.ts +24 -0
  150. package/lib/types/path-security.d.ts +26 -0
  151. package/lib/types/prefs-shared.d.ts +257 -0
  152. package/lib/types/pty-deps.d.ts +79 -0
  153. package/lib/types/pty-manager.d.ts +168 -0
  154. package/lib/types/tools.d.ts +34 -0
  155. package/lib/types/trust-fence.d.ts +24 -0
  156. package/lib/types/ui-tweaks/archive.d.ts +71 -0
  157. package/lib/types/ui-tweaks/config.d.ts +186 -0
  158. package/lib/types/ui-tweaks/git-web.d.ts +43 -0
  159. package/lib/types/ui-tweaks/git.d.ts +311 -0
  160. package/lib/types/ui-tweaks/index.d.ts +17 -0
  161. package/lib/types/ui-tweaks/mcp.d.ts +103 -0
  162. package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
  163. package/lib/types/ui-tweaks/web.d.ts +55 -0
  164. package/lib/types/wire.d.ts +38 -0
  165. package/package.json +213 -0
  166. package/scripts/install.ps1 +310 -0
  167. package/scripts/install.sh +287 -0
  168. package/src/agent-opens.ts +265 -0
  169. package/src/agent-pty.ts +527 -0
  170. package/src/browser-probe.ts +26 -0
  171. package/src/bundle-route.ts +129 -0
  172. package/src/chatcode-update.ts +193 -0
  173. package/src/client/AccountMenu.tsx +177 -0
  174. package/src/client/AiRatioSection.module.css +329 -0
  175. package/src/client/AiRatioSection.tsx +292 -0
  176. package/src/client/BrowserView.tsx +289 -0
  177. package/src/client/ChatcodeGate.tsx +35 -0
  178. package/src/client/CodingPlanModelsSettings.module.css +118 -0
  179. package/src/client/CodingPlanModelsSettings.tsx +101 -0
  180. package/src/client/DiffTab.tsx +114 -0
  181. package/src/client/DiffView.tsx +305 -0
  182. package/src/client/EditorHost.tsx +563 -0
  183. package/src/client/FenceErrorNotice.tsx +40 -0
  184. package/src/client/FileTree.tsx +701 -0
  185. package/src/client/FreeWindow.tsx +337 -0
  186. package/src/client/GitView.tsx +722 -0
  187. package/src/client/MarkdownHtml.tsx +296 -0
  188. package/src/client/OrphanedTab.tsx +27 -0
  189. package/src/client/PdfView.tsx +110 -0
  190. package/src/client/ReleaseNotesDialog.tsx +91 -0
  191. package/src/client/RenderBoundary.tsx +49 -0
  192. package/src/client/SandboxStatusBar.tsx +60 -0
  193. package/src/client/SideCardSection.module.css +932 -0
  194. package/src/client/SideCardSection.tsx +1169 -0
  195. package/src/client/Sidebar.tsx +1857 -0
  196. package/src/client/TabBar.tsx +365 -0
  197. package/src/client/TerminalView.tsx +424 -0
  198. package/src/client/TextEditor.tsx +468 -0
  199. package/src/client/TreePanel.tsx +279 -0
  200. package/src/client/UpdateDialog.tsx +186 -0
  201. package/src/client/UpdateSettingsPanel.tsx +29 -0
  202. package/src/client/UpdateSettingsSection.tsx +22 -0
  203. package/src/client/UploadOverlay.tsx +62 -0
  204. package/src/client/UsageHeatmapSection.module.css +440 -0
  205. package/src/client/UsageHeatmapSection.tsx +301 -0
  206. package/src/client/add-plugin-modal.tsx +199 -0
  207. package/src/client/api.ts +433 -0
  208. package/src/client/binary-download.tsx +23 -0
  209. package/src/client/breakpoints.ts +61 -0
  210. package/src/client/browser.ts +167 -0
  211. package/src/client/builtins/index.ts +37 -0
  212. package/src/client/builtins/tabs.tsx +275 -0
  213. package/src/client/builtins/viewers.tsx +125 -0
  214. package/src/client/chat-presentation.ts +95 -0
  215. package/src/client/chunk-loader.ts +350 -0
  216. package/src/client/chunks/editor.tsx +9 -0
  217. package/src/client/chunks/mermaid.tsx +10 -0
  218. package/src/client/chunks/terminal.tsx +9 -0
  219. package/src/client/cm-themes.ts +129 -0
  220. package/src/client/conversation-draft.ts +29 -0
  221. package/src/client/css-modules.d.ts +5 -0
  222. package/src/client/desktop-env.ts +72 -0
  223. package/src/client/editor-load.ts +92 -0
  224. package/src/client/frame-batcher.ts +56 -0
  225. package/src/client/icons.tsx +170 -0
  226. package/src/client/image-types.ts +8 -0
  227. package/src/client/ime-guard.ts +47 -0
  228. package/src/client/index.tsx +501 -0
  229. package/src/client/intercept.tsx +172 -0
  230. package/src/client/lang.ts +225 -0
  231. package/src/client/layout-push.ts +37 -0
  232. package/src/client/layout.css +156 -0
  233. package/src/client/lazy-chunk.tsx +89 -0
  234. package/src/client/link-intercept.ts +73 -0
  235. package/src/client/locales-ar.ts +387 -0
  236. package/src/client/locales-de.ts +372 -0
  237. package/src/client/locales-fr.ts +379 -0
  238. package/src/client/locales-hi.ts +386 -0
  239. package/src/client/locales-id.ts +384 -0
  240. package/src/client/locales-it.ts +377 -0
  241. package/src/client/locales-ja.ts +486 -0
  242. package/src/client/locales-ko.ts +378 -0
  243. package/src/client/locales-nl.ts +384 -0
  244. package/src/client/locales-pl.ts +388 -0
  245. package/src/client/locales-pt.ts +369 -0
  246. package/src/client/locales-ru.ts +384 -0
  247. package/src/client/locales-sv.ts +369 -0
  248. package/src/client/locales-th.ts +386 -0
  249. package/src/client/locales-tr.ts +386 -0
  250. package/src/client/locales-vi.ts +386 -0
  251. package/src/client/locales-zh-HK.ts +401 -0
  252. package/src/client/locales-zh-MO.ts +401 -0
  253. package/src/client/locales-zh-TW.ts +401 -0
  254. package/src/client/locales.ts +1229 -0
  255. package/src/client/markdown-html.ts +331 -0
  256. package/src/client/markdown-images.ts +137 -0
  257. package/src/client/markdown-labels.tsx +31 -0
  258. package/src/client/md-toc.tsx +127 -0
  259. package/src/client/mermaid-blocks.ts +110 -0
  260. package/src/client/mermaid-sanitize.ts +93 -0
  261. package/src/client/mermaid.tsx +406 -0
  262. package/src/client/open-when-sized.ts +43 -0
  263. package/src/client/open-with-settings.tsx +135 -0
  264. package/src/client/open-with.ts +221 -0
  265. package/src/client/openpath-intercept.ts +79 -0
  266. package/src/client/paths.ts +63 -0
  267. package/src/client/pdf-types.ts +4 -0
  268. package/src/client/pinned.ts +184 -0
  269. package/src/client/plugin-settings.ts +43 -0
  270. package/src/client/plugins-shared.ts +29 -0
  271. package/src/client/plugins-tabs.ts +153 -0
  272. package/src/client/plugins-viewers.ts +44 -0
  273. package/src/client/prefs.ts +216 -0
  274. package/src/client/produced-files.ts +120 -0
  275. package/src/client/selection-payload.ts +86 -0
  276. package/src/client/service.ts +873 -0
  277. package/src/client/session-pins.tsx +334 -0
  278. package/src/client/settings-nav-icon.ts +55 -0
  279. package/src/client/shell-presets.ts +83 -0
  280. package/src/client/sidebar.module.css +3607 -0
  281. package/src/client/split-pane.tsx +333 -0
  282. package/src/client/state.ts +1646 -0
  283. package/src/client/tab-content-memo.ts +55 -0
  284. package/src/client/terminal-font.ts +224 -0
  285. package/src/client/terminal-links.ts +194 -0
  286. package/src/client/theme.ts +110 -0
  287. package/src/client/titlebar-strip.ts +37 -0
  288. package/src/client/ui-tweaks/appicons.ts +24 -0
  289. package/src/client/ui-tweaks/archive.tsx +281 -0
  290. package/src/client/ui-tweaks/branding.tsx +232 -0
  291. package/src/client/ui-tweaks/cachehit.tsx +122 -0
  292. package/src/client/ui-tweaks/gitbar.tsx +2559 -0
  293. package/src/client/ui-tweaks/index.tsx +1696 -0
  294. package/src/client/ui-tweaks/mcp.tsx +582 -0
  295. package/src/client/ui-tweaks/notifier.ts +514 -0
  296. package/src/client/ui-tweaks/whale.tsx +171 -0
  297. package/src/client/upload.ts +187 -0
  298. package/src/client/useChatcodeAccount.ts +103 -0
  299. package/src/client/wco.ts +120 -0
  300. package/src/codingplan.ts +146 -0
  301. package/src/config.ts +207 -0
  302. package/src/context-types.ts +559 -0
  303. package/src/fs-operations.ts +97 -0
  304. package/src/fs-search.ts +113 -0
  305. package/src/fs-tree.ts +158 -0
  306. package/src/git.ts +446 -0
  307. package/src/html-route.ts +106 -0
  308. package/src/index.ts +1524 -0
  309. package/src/invariant.ts +32 -0
  310. package/src/login-route.ts +26 -0
  311. package/src/open-external.ts +90 -0
  312. package/src/path-security.ts +81 -0
  313. package/src/prefs-shared.ts +303 -0
  314. package/src/pty-deps.ts +241 -0
  315. package/src/pty-manager.ts +376 -0
  316. package/src/tools.ts +482 -0
  317. package/src/trust-fence.ts +84 -0
  318. package/src/ui-tweaks/archive.ts +293 -0
  319. package/src/ui-tweaks/config.ts +257 -0
  320. package/src/ui-tweaks/git-web.ts +421 -0
  321. package/src/ui-tweaks/git.ts +1524 -0
  322. package/src/ui-tweaks/index.ts +61 -0
  323. package/src/ui-tweaks/mcp.ts +535 -0
  324. package/src/ui-tweaks/turn-outcome.ts +105 -0
  325. package/src/ui-tweaks/web.ts +225 -0
  326. package/src/wire.ts +110 -0
  327. package/vendor/addon-fit.js +2 -0
  328. package/vendor/xterm.css +218 -0
  329. package/vendor/xterm.js +5 -0
@@ -0,0 +1,514 @@
1
+ /**
2
+ * dsh-ui-tweaks — task notifications (browser half).
3
+ *
4
+ * Watches the sessions list feed (the same store the sidebar's running flag,
5
+ * amber interaction dot and green "done" reminder project from) and raises a
6
+ * browser-side heads-up when a session finishes its turn or starts blocking
7
+ * on the user — so a backgrounded tab can call you back:
8
+ *
9
+ * - "Finished" = a session's `running` flag drops (true → false) without an
10
+ * interaction taking over, or its host-tracked `completed` reminder rises
11
+ * (finished while not selected). The host `dshTurnOutcome` session
12
+ * projection (`src/turn-outcome.ts`, folded from `turn/end` reasons) says
13
+ * WHY it finished, so the copy distinguishes a clean completion from a
14
+ * user abort and from a failed request (with the error text); when the
15
+ * projection is missing or stale, falls back to a plain-finish announce.
16
+ * - "Interaction" = a session gains `pendingInteraction`
17
+ * ('approval' | 'plan-review' | 'question') — exactly the sidebar's
18
+ * amber-dot classification.
19
+ *
20
+ * Three independent channels, each silently degrading when unavailable:
21
+ * - Title flash: alternates `(N) 🔔 <title>` ↔ `(N) <title>` until the page
22
+ * is visible and focused again, then restores the captured title. DSH may
23
+ * rewrite `document.title` independently; while flashing our ticks win,
24
+ * and on stop we restore the pre-flash capture (worst case a stale base).
25
+ * - System notification: the Web Notifications API; permission is requested
26
+ * from the settings toggle's user gesture (`requestNotifyPermission`).
27
+ * Clicking one focuses the window and opens that session. `silent` tracks
28
+ * the chime channel so the two never double-beep.
29
+ * - Chime: a tiny WebAudio two-note motif (rising = done, falling = needs
30
+ * you), synthesized in-process — no audio assets. Autoplay policy allows
31
+ * this once the user has interacted with the origin (sticky activation),
32
+ * which the settings-toggle clicks provide.
33
+ *
34
+ * Anti-spam: the first snapshot after install only arms the baseline (a page
35
+ * load never fires a burst); events fire on transitions only; a short
36
+ * per-session+kind cooldown absorbs reconnect re-pull flicker. Subagent child
37
+ * rows are skipped — their parent carries the turn, and mid-turn subagent
38
+ * completions would be noise.
39
+ * @module dsh-ui-tweaks/client/notifier
40
+ */
41
+
42
+ import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
43
+ import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
44
+ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
45
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
46
+ import type { TurnOutcomeValue } from '../../ui-tweaks/turn-outcome.ts'
47
+
48
+ declare module '@deepseek-ai/dsh-session-projection/types' {
49
+ interface SessionProjectionMap {
50
+ dshTurnOutcome: TurnOutcomeValue | null
51
+ }
52
+ }
53
+
54
+ /** List-row key carrying the turn outcome; keep in sync with src/turn-outcome.ts. */
55
+ const TURN_OUTCOME_KEY = 'dshTurnOutcome'
56
+
57
+ /** Interaction kinds the host reports while a session blocks on the user. */
58
+ export type NotifyPendingKind = 'approval' | 'plan-review' | 'question'
59
+
60
+ /** Event classes the notifier can raise. */
61
+ type NotifyEventKind = 'complete' | 'aborted' | 'failed' | 'interaction'
62
+
63
+ /** Terminal-turn announcement kinds — they all answer "your task ended". */
64
+ const TERMINAL_KINDS = ['complete', 'aborted', 'failed'] as const
65
+
66
+ /** Localized copy snapshot the notifier needs (resolved once at install). */
67
+ export interface NotifierText {
68
+ /** System-notification heading for completion events. */
69
+ notifyTitleDone: string
70
+ /** System-notification heading for user-interrupted turns. */
71
+ notifyTitleAborted: string
72
+ /** System-notification heading for failed requests. */
73
+ notifyTitleFailed: string
74
+ /** System-notification heading for interaction events. */
75
+ notifyTitlePending: string
76
+ /** Completion body template; `{title}` becomes the session display title. */
77
+ bodyComplete: string
78
+ /** Interruption body template. */
79
+ bodyAborted: string
80
+ /** Failure body template; `{error}` becomes the truncated failure message. */
81
+ bodyFailed: string
82
+ /** Approval body template. */
83
+ bodyApproval: string
84
+ /** Plan-review body template. */
85
+ bodyPlan: string
86
+ /** Question body template. */
87
+ bodyQuestion: string
88
+ }
89
+
90
+ /**
91
+ * Live-read behavior switches — read through {@link TaskNotifierInput.readState}
92
+ * on every feed tick so settings-panel flips apply without reinstalling.
93
+ */
94
+ export interface NotifierOptions {
95
+ /** Raise alerts only while the page is hidden or unfocused. */
96
+ onlyWhenHidden: boolean
97
+ /** Alert when a session finishes its turn. */
98
+ onComplete: boolean
99
+ /** Alert when a session starts waiting on the user. */
100
+ onInteraction: boolean
101
+ }
102
+
103
+ /** Output channels, each independently toggleable. */
104
+ export interface NotifierChannels {
105
+ titleFlash: boolean
106
+ systemNotification: boolean
107
+ sound: boolean
108
+ }
109
+
110
+ /** Install-time wiring for {@link installTaskNotifier}. */
111
+ export interface TaskNotifierInput {
112
+ /** The client sessions service whose list feed carries the watch states. */
113
+ sessionsService: ISessions
114
+ /** Current Session-scoped interactions, published separately from list rows. */
115
+ pendingInteractions: HostObservable<SessionPendingInteractionSnapshot>
116
+ /** Localized copy snapshot used across the notifier's lifetime. */
117
+ text: NotifierText
118
+ /** Fresh behavior switches + channel toggles, read on every tick. */
119
+ readState(): { options: NotifierOptions; channels: NotifierChannels }
120
+ }
121
+
122
+ /** Per-session state the transition detector compares against. */
123
+ interface WatchState {
124
+ running: boolean
125
+ pending: NotifyPendingKind | undefined
126
+ completed: boolean
127
+ outcome: TurnOutcomeValue | undefined
128
+ }
129
+
130
+ /** Narrow the extensible host interaction roster to the notification copy we own. */
131
+ function pendingKind(
132
+ snapshot: SessionPendingInteractionSnapshot,
133
+ sessionId: SessionId,
134
+ ): NotifyPendingKind | undefined {
135
+ switch (snapshot.get(sessionId)?.kind) {
136
+ case 'approval':
137
+ return 'approval'
138
+ case 'plan-review':
139
+ return 'plan-review'
140
+ case 'question':
141
+ return 'question'
142
+ default:
143
+ return undefined
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Map a logged `turn/end` reason onto an announcement kind. Unknown or absent
149
+ * outcomes (older host, projection lag) degrade to a plain completion.
150
+ */
151
+ const classifyFinish = (outcome: TurnOutcomeValue | undefined): 'complete' | 'aborted' | 'failed' => {
152
+ switch (outcome?.kind) {
153
+ case 'aborted':
154
+ case 'interrupted':
155
+ return 'aborted'
156
+ case 'error':
157
+ return 'failed'
158
+ default:
159
+ return 'complete'
160
+ }
161
+ }
162
+
163
+ /** Same event kind for one session never repeats inside this window. */
164
+ const COOLDOWN_MS = 2000
165
+ /**
166
+ * A background session's finish usually announces via the running-drop; the
167
+ * host's green `completed` reminder often lands a re-pull later — treat it as
168
+ * the SAME completion inside this window instead of re-toasting it.
169
+ */
170
+ const COMPLETE_RENOTIFY_MS = 30000
171
+ /** Title-flash blink period. */
172
+ const FLASH_INTERVAL_MS = 900
173
+
174
+ /**
175
+ * `NotificationOptions` plus Chrome's `renotify` (re-alert on tag replace).
176
+ * The option is real and supported at runtime, but this project's DOM lib
177
+ * predates its type declaration, so it is declared locally.
178
+ */
179
+ interface RealertableNotificationOptions extends NotificationOptions {
180
+ renotify?: boolean
181
+ }
182
+
183
+ /** Replace the `{title}` / `{error}` placeholders (split/join — no regex escaping worries). */
184
+ function fill(template: string, title: string, error: string | undefined): string {
185
+ return template.split('{error}').join(error ?? '').split('{title}').join(title)
186
+ }
187
+
188
+ /**
189
+ * Ask the browser for desktop-notification permission. Must run inside a user
190
+ * gesture (the settings toggle's click) to avoid a denied or ignored prompt;
191
+ * repeated calls are cheap no-ops once decided.
192
+ */
193
+ export function requestNotifyPermission(): void {
194
+ try {
195
+ if (typeof Notification === 'undefined') return
196
+ if (Notification.permission === 'default') void Notification.requestPermission()
197
+ } catch {
198
+ // Notifications unsupported — the other channels carry on.
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Forced title-flash preview for the settings panel's 测试 button: the real
204
+ * flash auto-clears the moment its page is visible AND focused — which is
205
+ * exactly the tester's situation, so a plain preview would heal instantly and
206
+ * show nothing. Instead this blinks unconditionally for a few seconds, then
207
+ * restores whatever title was current at click time.
208
+ */
209
+ let previewBlinkTimer: number | undefined
210
+ let previewStopTimer: number | undefined
211
+
212
+ export function previewTitleFlash(durationMs = 6000): void {
213
+ // Restart cleanly on rapid re-clicks.
214
+ if (previewBlinkTimer !== undefined) window.clearInterval(previewBlinkTimer)
215
+ if (previewStopTimer !== undefined) window.clearTimeout(previewStopTimer)
216
+ const base = document.title
217
+ let tick = false
218
+ const blink = (): void => {
219
+ tick = !tick
220
+ document.title = tick ? `(1) 🔔 ${base}` : `(1) ${base}`
221
+ }
222
+ blink()
223
+ previewBlinkTimer = window.setInterval(blink, FLASH_INTERVAL_MS)
224
+ previewStopTimer = window.setTimeout(() => {
225
+ if (previewBlinkTimer !== undefined) {
226
+ window.clearInterval(previewBlinkTimer)
227
+ previewBlinkTimer = undefined
228
+ }
229
+ previewStopTimer = undefined
230
+ document.title = base
231
+ }, durationMs)
232
+ }
233
+
234
+ /**
235
+ * One-shot self-test for the settings panel's 测试 button: previews every
236
+ * enabled channel — a forced ~6s title-flash blink (the real one would clear
237
+ * instantly on a focused page), the falling (needs-you) chime, and, when
238
+ * permission is already granted, one sample system notification.
239
+ */
240
+ export function previewAlerts(channels: NotifierChannels, text: NotifierText): void {
241
+ requestNotifyPermission()
242
+ if (channels.titleFlash) previewTitleFlash()
243
+ if (channels.sound) playChime('down')
244
+ if (!channels.systemNotification || typeof Notification === 'undefined') return
245
+ if (Notification.permission !== 'granted') return
246
+ try {
247
+ const sample = fill(text.bodyComplete, 'ChatCode CLI', undefined)
248
+ new Notification(text.notifyTitleDone, { body: sample, tag: 'dsh-ui-tweaks:preview', silent: true })
249
+ } catch {
250
+ // Construction can throw on some platforms — degrade quietly.
251
+ }
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Chime — WebAudio two-note motif, synthesized, no assets.
256
+ // ---------------------------------------------------------------------------
257
+
258
+ let audioCtx: AudioContext | undefined
259
+
260
+ function playChime(shape: 'up' | 'down'): void {
261
+ try {
262
+ audioCtx ??= new AudioContext()
263
+ const ctx = audioCtx
264
+ // Sticky activation from earlier clicks usually suffices; resume is best effort.
265
+ if (ctx.state === 'suspended') void ctx.resume().catch(() => {})
266
+ const now = ctx.currentTime
267
+ const notes = shape === 'up' ? [880, 1318.51] : [1174.66, 783.99]
268
+ // 240ms between notes with a long decay each: tight spacing read as ONE
269
+ // blip in listening tests; this wide a gap keeps the two tones distinct
270
+ // (rising = done, falling = needs you).
271
+ notes.forEach((freq, i) => {
272
+ const osc = ctx.createOscillator()
273
+ const gain = ctx.createGain()
274
+ osc.type = 'sine'
275
+ osc.frequency.value = freq
276
+ const t0 = now + i * 0.24
277
+ gain.gain.setValueAtTime(0.0001, t0)
278
+ gain.gain.exponentialRampToValueAtTime(0.22, t0 + 0.03)
279
+ gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.2)
280
+ osc.connect(gain)
281
+ gain.connect(ctx.destination)
282
+ osc.start(t0)
283
+ osc.stop(t0 + 0.24)
284
+ })
285
+ } catch {
286
+ // Audio unavailable (autoplay policy, missing AudioContext) — stay silent.
287
+ }
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Installer.
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /**
295
+ * Install the task notifier against the sessions list feed.
296
+ * @returns disposer — unsubscribes, restores the tab title and tears the channels down.
297
+ */
298
+ export function installTaskNotifier(input: TaskNotifierInput): () => void {
299
+ const { sessionsService, pendingInteractions, text, readState } = input
300
+ const list = sessionsService.list
301
+
302
+ // --- detector state ------------------------------------------------------
303
+ const watch = new Map<SessionId, WatchState>()
304
+ const lastFired = new Map<string, number>()
305
+
306
+ // --- title flash ---------------------------------------------------------
307
+ let originalTitle: string | null = null
308
+ let flashTimer: number | undefined
309
+ let flashTick = false
310
+ let unread = 0
311
+
312
+ const visibleAndFocused = (): boolean => document.visibilityState === 'visible' && document.hasFocus()
313
+
314
+ const startTitleFlash = (): void => {
315
+ if (flashTimer !== undefined) return
316
+ originalTitle = document.title
317
+ flashTimer = window.setInterval(() => {
318
+ flashTick = !flashTick
319
+ const base = originalTitle ?? ''
320
+ document.title = flashTick ? `(${unread}) 🔔 ${base}` : `(${unread}) ${base}`
321
+ }, FLASH_INTERVAL_MS)
322
+ }
323
+
324
+ const stopTitleFlash = (): void => {
325
+ if (flashTimer !== undefined) {
326
+ window.clearInterval(flashTimer)
327
+ flashTimer = undefined
328
+ }
329
+ if (originalTitle !== null) document.title = originalTitle
330
+ originalTitle = null
331
+ unread = 0
332
+ }
333
+
334
+ /** Coming back to a visible, focused page acknowledges everything. */
335
+ const settleIfBack = (): void => {
336
+ if (visibleAndFocused()) stopTitleFlash()
337
+ }
338
+ const onVisibilityChange = (): void => {
339
+ settleIfBack()
340
+ }
341
+ window.addEventListener('focus', settleIfBack)
342
+ document.addEventListener('visibilitychange', onVisibilityChange)
343
+
344
+ // --- system notification -------------------------------------------------
345
+ const fireSystemNotification = (
346
+ kind: NotifyEventKind,
347
+ sessionId: SessionId,
348
+ heading: string,
349
+ body: string,
350
+ chimeOn: boolean,
351
+ ): void => {
352
+ if (typeof Notification === 'undefined') return
353
+ if (Notification.permission !== 'granted') return
354
+ try {
355
+ // `silent` mirrors the chime channel: our motif is THE sound, so mute
356
+ // the OS bark while it will play; otherwise let the platform ring.
357
+ // `renotify` forces a fresh banner when this tag REPLACES an existing
358
+ // toast — without it Chrome swaps the old toast silently and Windows
359
+ // collapses it into the notification center.
360
+ const options: RealertableNotificationOptions = {
361
+ body,
362
+ tag: `dsh-ui-tweaks:${sessionId}:${kind}`,
363
+ silent: chimeOn,
364
+ renotify: true,
365
+ }
366
+ const n = new Notification(heading, options)
367
+ n.onclick = (): void => {
368
+ try { window.focus() } catch { /* ignore */ }
369
+ try { sessionsService.open(sessionId) } catch { /* session already gone — focus alone is fine */ }
370
+ n.close()
371
+ }
372
+ } catch {
373
+ // Construction can throw on some platforms — degrade quietly.
374
+ }
375
+ }
376
+
377
+ // --- delivery ------------------------------------------------------------
378
+ const deliver = (
379
+ sessionId: SessionId,
380
+ displayTitle: string,
381
+ kind: NotifyEventKind,
382
+ pending: NotifyPendingKind | undefined,
383
+ detail: string | undefined,
384
+ channels: NotifierChannels,
385
+ now: number,
386
+ ): void => {
387
+ const cooldownKey = `${sessionId}:${kind}`
388
+ const last = lastFired.get(cooldownKey)
389
+ if (last !== undefined && now - last < COOLDOWN_MS) return
390
+ lastFired.set(cooldownKey, now)
391
+
392
+ unread += 1
393
+ if (channels.titleFlash) startTitleFlash()
394
+ if (!channels.systemNotification && !channels.sound) return
395
+
396
+ const heading = kind === 'complete'
397
+ ? text.notifyTitleDone
398
+ : kind === 'aborted'
399
+ ? text.notifyTitleAborted
400
+ : kind === 'failed'
401
+ ? text.notifyTitleFailed
402
+ : text.notifyTitlePending
403
+ let body = kind === 'aborted'
404
+ ? text.bodyAborted
405
+ : kind === 'failed'
406
+ ? text.bodyFailed
407
+ : text.bodyComplete
408
+ if (kind === 'interaction') {
409
+ body = pending === 'approval'
410
+ ? text.bodyApproval
411
+ : pending === 'plan-review'
412
+ ? text.bodyPlan
413
+ : text.bodyQuestion
414
+ }
415
+ fireSystemNotification(kind, sessionId, heading, fill(body, displayTitle, detail), channels.sound)
416
+ if (channels.sound) playChime(kind === 'complete' ? 'up' : 'down')
417
+ }
418
+
419
+ // --- baseline arming -----------------------------------------------------
420
+ /** Seed the watch map without firing: fresh installs and reloads stay quiet. */
421
+ const armBaseline = (): void => {
422
+ watch.clear()
423
+ const interactions = pendingInteractions.getSnapshot()
424
+ for (const row of Object.values(list.getSnapshot().byId)) {
425
+ if (row.parentId !== undefined || row.blank) continue
426
+ const outcome = row.projectionValues?.[TURN_OUTCOME_KEY] ?? undefined
427
+ watch.set(row.id, {
428
+ running: row.running,
429
+ pending: pendingKind(interactions, row.id),
430
+ completed: row.completed === true,
431
+ outcome,
432
+ })
433
+ }
434
+ }
435
+
436
+ // --- feed tick -----------------------------------------------------------
437
+ const check = (): void => {
438
+ const { options, channels } = readState()
439
+ const deliverable = !options.onlyWhenHidden || !visibleAndFocused()
440
+ const now = Date.now()
441
+ const snap = list.getSnapshot()
442
+ const interactions = pendingInteractions.getSnapshot()
443
+ const seen = new Set<SessionId>()
444
+
445
+ for (const row of Object.values(snap.byId)) {
446
+ if (row.parentId !== undefined || row.blank) continue
447
+ seen.add(row.id)
448
+ const prev = watch.get(row.id)
449
+ const outcome = row.projectionValues?.[TURN_OUTCOME_KEY] ?? undefined
450
+ const pending = pendingKind(interactions, row.id)
451
+ watch.set(row.id, { running: row.running, pending, completed: row.completed === true, outcome })
452
+ if (prev === undefined) continue
453
+
454
+ // Interaction outranks completion: a turn ending ON a question must
455
+ // announce the question, not a completion.
456
+ if (prev.pending === undefined && pending !== undefined) {
457
+ if (options.onInteraction && deliverable) {
458
+ deliver(row.id, row.displayTitle, 'interaction', pending, undefined, channels, now)
459
+ }
460
+ continue
461
+ }
462
+
463
+ const finished = prev.running && !row.running && pending === undefined
464
+ const completedReminder = !prev.completed && row.completed === true
465
+ if (finished || completedReminder) {
466
+ // The green `completed` reminder often arrives a re-pull AFTER the
467
+ // running-drop already announced the same finish — inside the
468
+ // re-notify window it is a duplicate, not a new completion.
469
+ let shouldFire = finished
470
+ if (!shouldFire) {
471
+ // Any recent terminal announcement (of any outcome kind) makes the
472
+ // green-dot reminder a duplicate of the same finish.
473
+ shouldFire = TERMINAL_KINDS.every((kind) => {
474
+ const last = lastFired.get(`${row.id}:${kind}`)
475
+ return last === undefined || now - last >= COMPLETE_RENOTIFY_MS
476
+ })
477
+ }
478
+ if (shouldFire && options.onComplete && deliverable) {
479
+ // Trust the outcome only when it CHANGED on this very transition —
480
+ // a stale value from an earlier turn would mislabel the announce.
481
+ const fresh = prev.outcome === undefined
482
+ || outcome?.time !== prev.outcome.time
483
+ || outcome?.kind !== prev.outcome.kind
484
+ const announcement = fresh && outcome !== undefined ? classifyFinish(outcome) : 'complete'
485
+ deliver(row.id, row.displayTitle, announcement, undefined, outcome?.errorMessage, channels, now)
486
+ }
487
+ }
488
+ }
489
+
490
+ // Forget removed sessions so a re-added id arms fresh instead of comparing
491
+ // against stale pre-removal state.
492
+ for (const id of watch.keys()) {
493
+ if (!seen.has(id)) watch.delete(id)
494
+ }
495
+ }
496
+
497
+ armBaseline()
498
+ const disposeFeed = list.subscribe(check)
499
+ const disposeInteractions = pendingInteractions.subscribe(check)
500
+
501
+ return () => {
502
+ disposeFeed()
503
+ disposeInteractions()
504
+ stopTitleFlash()
505
+ window.removeEventListener('focus', settleIfBack)
506
+ document.removeEventListener('visibilitychange', onVisibilityChange)
507
+ watch.clear()
508
+ lastFired.clear()
509
+ if (audioCtx !== undefined) {
510
+ void audioCtx.close().catch(() => {})
511
+ audioCtx = undefined
512
+ }
513
+ }
514
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * dsh-ui-tweaks — whale working indicator (browser half).
3
+ *
4
+ * A compact China-knot brand mark perched on the composer card's top-right
5
+ * corner over drifting red waves. It is resident while the feature is on;
6
+ * idle it rests in brand red, and while the model works it floats and breathes
7
+ * through the red accent spectrum.
8
+ *
9
+ * - Mounted in `conversation.input.dock` (the full-width row stacked by the
10
+ * composer column right above the card), registered only while the
11
+ * `whaleIndicatorEnabled` toggle is on — off costs nothing. The dock row is
12
+ * WIDER than the card (the card is capped at
13
+ * `--dsh-composer-card-max-width` and centered with
14
+ * `--dsh-composer-side-clearance` side padding by the InputBar root), so
15
+ * the row replicates that exact geometry — same max-width, centered, same
16
+ * side padding — before right-aligning; a 6px `translateY` then straddles
17
+ * the card's top edge. The extra 14px right inset centers the whale over
18
+ * the send button (34px round button, 8px tool-row padding → its center
19
+ * sits 25px from the card's right edge; the 22px whale's right edge at
20
+ * 14px puts its center at 25px).
21
+ * - "Working" = the current session's `running` flag on the sessions list
22
+ * feed, OR the input machine's claimed/submitting phases (the window
23
+ * between pressing Enter and the agent actually starting to run).
24
+ * - The mark is self-contained rather than lifted from the host sidebar, so
25
+ * its branding remains consistent while DSH changes its own logo.
26
+ * @module dsh-ui-tweaks/client/whale
27
+ */
28
+
29
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
30
+ import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
31
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
32
+
33
+ /**
34
+ * China-knot-inspired geometry, painted in `currentColor`.
35
+ */
36
+ const KNOT_PATHS = [
37
+ 'M32 5c-8.5 0-15.5 6.9-15.5 15.5 0 8.6 6.1 12.8 15.5 18.8 9.4-6 15.5-10.2 15.5-18.8C47.5 11.9 40.5 5 32 5Z',
38
+ 'M32 59c8.5 0 15.5-6.9 15.5-15.5 0-8.6-6.1-12.8-15.5-18.8-9.4 6-15.5 10.2-15.5 18.8C16.5 52.1 23.5 59 32 59Z',
39
+ 'M5 32c0-8.5 6.9-15.5 15.5-15.5 8.6 0 12.8 6.1 18.8 15.5-6 9.4-10.2 15.5-18.8 15.5C11.9 47.5 5 40.5 5 32Z',
40
+ 'M59 32c0 8.5-6.9 15.5-15.5 15.5-8.6 0-12.8-6.1-18.8-15.5 6-9.4 10.2-15.5 18.8-15.5C52.1 16.5 59 23.5 59 32Z',
41
+ ]
42
+
43
+ /** Brand mark width in px; square geometry keeps it crisp beside Send. */
44
+ const KNOT_WIDTH = 22
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Styles — resident (translucent, still) vs working (opaque, swimming).
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export const WHALE_CSS = `
51
+ .duwi-row{box-sizing:border-box;width:100%;max-width:calc(var(--dsh-composer-card-max-width) + 2 * var(--dsh-composer-side-clearance, 0px));margin:0 auto;padding:0 calc(var(--dsh-composer-side-clearance, 0px) + 14px) 0 var(--dsh-composer-side-clearance, 0px);display:flex;align-items:flex-end;justify-content:flex-end;height:27px;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
52
+ .duwi-stack{display:flex;flex-direction:column;align-items:center;transform:translateY(6px)}
53
+ .duwi-whale{display:block;color:#9B0B09;transform-origin:50% 60%;pointer-events:auto;cursor:pointer}
54
+ .duwi-row.duwi-swim .duwi-whale{animation:duwi-swim 1.9s ease-in-out infinite,duwi-breathe 1.9s ease-in-out infinite}
55
+ .duwi-whale:hover{animation:duwi-swim 1.9s ease-in-out infinite}
56
+ .duwi-waves{display:block;width:26px;height:9px;margin-top:-3px;overflow:hidden}
57
+ .duwi-wave-back{stroke:#d96a65;opacity:.35}
58
+ .duwi-wave-front{stroke:#9B0B09;opacity:.8}
59
+ .duwi-wave-front,.duwi-wave-back{animation:duwi-drift-front 1.9s linear infinite}
60
+ .duwi-wave-back{animation-duration:3.4s;animation-direction:reverse}
61
+ @keyframes duwi-swim{
62
+ 0%,100%{transform:translateY(0) rotate(-4deg)}
63
+ 50%{transform:translateY(-3.5px) rotate(4deg)}
64
+ }
65
+ @keyframes duwi-breathe{
66
+ 0%,100%{color:#9B0B09}
67
+ 50%{color:#d94b45}
68
+ }
69
+ @keyframes duwi-drift-front{
70
+ from{transform:translateX(0)}
71
+ to{transform:translateX(-8px)}
72
+ }
73
+ @media (prefers-reduced-motion:reduce){
74
+ .duwi-row.duwi-swim .duwi-whale,.duwi-whale:hover{animation:none}
75
+ .duwi-wave-front,.duwi-wave-back{animation:none}
76
+ }
77
+ `
78
+
79
+ /** Idempotent installer for the indicator styles (same pattern as the other features). */
80
+ export function installWhaleStyles(): () => void {
81
+ const id = 'dsh-ui-tweaks-whale'
82
+ const existing = document.querySelector(`style[data-plugin-css="${id}"]`)
83
+ if (existing !== null) return () => {}
84
+ const style = document.createElement('style')
85
+ style.dataset.plugin = 'dsh-ui-tweaks'
86
+ style.dataset.pluginCss = id
87
+ style.textContent = WHALE_CSS
88
+ document.head.appendChild(style)
89
+ return () => { style.remove() }
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Live whale geometry refresh.
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ // ---------------------------------------------------------------------------
98
+ // Component.
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /**
102
+ * Structural slice of the framework `InputState` (not re-exported by the
103
+ * package's client entry) — exactly the fields the indicator reads off the
104
+ * dock's owner share.
105
+ */
106
+ interface InputPhaseSnapshot {
107
+ phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
108
+ }
109
+
110
+ export interface WhaleIndicatorProps {
111
+ /** Framework seat (PropsRuntime): current session id. */
112
+ sessionId: SessionId | undefined
113
+ /** Owner share (InputZone): live input-machine snapshot — covers the send → run window. */
114
+ input: InputPhaseSnapshot
115
+ /** Injected: the client sessions service (list feed carrying `running`). */
116
+ sessionsService: ISessions
117
+ }
118
+
119
+ export function WhaleIndicator({ sessionId, input, sessionsService }: WhaleIndicatorProps) {
120
+ // Idle easter egg: a click swims the whale for a couple of cycles.
121
+ const [peek, setPeek] = useState(false)
122
+ const peekTimer = useRef<number | undefined>(undefined)
123
+ useEffect(() => () => { window.clearTimeout(peekTimer.current) }, [])
124
+ const swimFor = (): void => {
125
+ setPeek(true)
126
+ window.clearTimeout(peekTimer.current)
127
+ peekTimer.current = window.setTimeout(() => { setPeek(false) }, 3800)
128
+ }
129
+
130
+ // The current session's `running` flag, straight off the sessions list feed.
131
+ const list = sessionsService.list
132
+ const subscribe = useMemo(() => list.subscribe.bind(list), [list])
133
+ const running = useSyncExternalStore(
134
+ subscribe,
135
+ () => {
136
+ const snapshot = list.getSnapshot()
137
+ const id = sessionId ?? snapshot.current
138
+ return id !== undefined && (snapshot.byId[id]?.running ?? false)
139
+ },
140
+ () => false,
141
+ )
142
+
143
+ // `claimed`/`submitting` bridge the gap between Enter and the agent start.
144
+ const submitting = input.phase === 'claimed' || input.phase === 'submitting'
145
+ const swim = running || submitting || peek
146
+
147
+ return (
148
+ <div className={swim ? 'duwi-row duwi-swim' : 'duwi-row'} aria-hidden="true">
149
+ <div className="duwi-stack">
150
+ <svg
151
+ className="duwi-whale"
152
+ width={KNOT_WIDTH}
153
+ height={KNOT_WIDTH}
154
+ viewBox="0 0 64 64"
155
+ fill="none"
156
+ onClick={swimFor}
157
+ >
158
+ <g stroke="currentColor" strokeWidth="5.2" strokeLinecap="round" strokeLinejoin="round">
159
+ {KNOT_PATHS.map((path) => <path key={path} d={path} />)}
160
+ </g>
161
+ <circle cx="32" cy="32" r="5.5" fill="currentColor" />
162
+ </svg>
163
+ <svg className="duwi-waves" viewBox="0 0 26 9" fill="none">
164
+ {/* Wavelength-8 sine strips drawn from -8 to +40 so the ±8px drift loop stays covered. */}
165
+ <path className="duwi-wave-back" d="M-8 4 Q-4 5.8 0 4 T8 4 T16 4 T24 4 T32 4 T40 4" strokeWidth="1.4" strokeLinecap="round" />
166
+ <path className="duwi-wave-front" d="M-8 5.5 Q-4 3.3 0 5.5 T8 5.5 T16 5.5 T24 5.5 T32 5.5 T40 5.5" strokeWidth="1.4" strokeLinecap="round" />
167
+ </svg>
168
+ </div>
169
+ </div>
170
+ )
171
+ }