ompchamber 1.0.0 → 1.2.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 (233) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Full-history user-turn index for the timeline jump rail.
8
+ *
9
+ * The timeline mounts a window of a long session (newest 120 messages plus
10
+ * whatever "Load earlier messages" paged in), so a rail built from the mounted
11
+ * rows lists only that fraction — the bug this hook exists to fix. The index
12
+ * comes from `/api/chat/:id/turns`, which walks the whole session with the same
13
+ * id/merge pipeline as the timeline, so a rail tick always resolves to a row
14
+ * the timeline can actually reach by paging.
15
+ *
16
+ * Live rows the committed history does not carry yet (an optimistic send in
17
+ * flight) are appended with `index: -1` — they are on screen already, and
18
+ * dropping them would make the rail lag one turn behind the composer.
19
+ */
20
+
21
+ import { useEffect, useMemo, useState } from 'preact/hooks';
22
+ import { TURN_PREVIEW_CHARS } from '@/shared/lib/chat/timeline/turns';
23
+ import type { ChatMessageData, UserTurnRef } from '@/shared/types';
24
+
25
+ /** Appended live rows are already mounted; the jump reads this as "no paging
26
+ * needed" rather than as a position in the committed list. */
27
+ const LIVE_TURN_INDEX = -1;
28
+
29
+ export interface UserTurnsState {
30
+ /** Every user turn of the session, oldest first. */
31
+ turns: UserTurnRef[];
32
+ /** A jump is walking history to reach the clicked turn. */
33
+ jumping: boolean;
34
+ /** Resolve a turn's row and bring it into view; false when unreachable. */
35
+ jumpToTurn: (id: string, index: number) => Promise<boolean>;
36
+ }
37
+
38
+ export interface UseUserTurnsDeps {
39
+ sessionId: string | null;
40
+ /** Mounted timeline: its user rows are merged into the index so a just-sent
41
+ * turn is on the rail before the committed history carries it — and they
42
+ * ARE the index until the fetch lands, so the rail is never blank on a
43
+ * session that already shows turns. */
44
+ messages: ChatMessageData[];
45
+ /** Pages history until the turn's row is mounted (session-pagination). */
46
+ jumpToTurn: (id: string, index: number) => Promise<boolean>;
47
+ }
48
+
49
+ /** How long a jump waits for the row it paged in to commit. A single prepend
50
+ * can add hundreds of rows, so this is wall-clock bounded rather than
51
+ * frame-counted — 30 frames is not enough to lay out a large window. */
52
+ const ROW_COMMIT_TIMEOUT_MS = 3000;
53
+
54
+ /** Wait for a row the paging walk just requested to commit, then center it.
55
+ * The prepend and the jump's own scroll are separate commits: the walk
56
+ * resolves before the DOM has the node, so this polls until it appears
57
+ * instead of scrolling to a row that does not exist yet. */
58
+ function scrollToRow(id: string, deadline = Date.now() + ROW_COMMIT_TIMEOUT_MS): void {
59
+ const el = document.getElementById(id);
60
+ if (el) {
61
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
62
+ return;
63
+ }
64
+ if (Date.now() >= deadline) return;
65
+ requestAnimationFrame(() => scrollToRow(id, deadline));
66
+ }
67
+
68
+ export function useUserTurns(deps: UseUserTurnsDeps): UserTurnsState {
69
+ const { sessionId, messages, jumpToTurn } = deps;
70
+ const [indexed, setIndexed] = useState<UserTurnRef[] | null>(null);
71
+ const [jumping, setJumping] = useState(false);
72
+
73
+ // Mounted user rows, as rail entries. `index: -1` says "already on screen" —
74
+ // true by construction here, and true for the mounted rows the full index
75
+ // does not carry yet (a live send, a turn committed after the fetch).
76
+ const mountedTurns = useMemo(() => {
77
+ const rows: UserTurnRef[] = [];
78
+ for (const message of messages) {
79
+ if (message.role !== 'user') continue;
80
+ rows.push({
81
+ id: message.id,
82
+ index: LIVE_TURN_INDEX,
83
+ preview: (message.content ?? '').slice(0, TURN_PREVIEW_CHARS),
84
+ date: message.date,
85
+ timestamp: message.timestamp,
86
+ });
87
+ }
88
+ return rows;
89
+ }, [messages]);
90
+
91
+ // The last mounted user row id is the cheapest "the committed history moved"
92
+ // signal: a send, a commit reconciliation, or an undo/rewind changes it,
93
+ // while paging older windows does not — so the index refetches exactly when
94
+ // its content can have gone stale.
95
+ const lastUserId = useMemo(() => {
96
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
97
+ if (messages[i]?.role === 'user') return messages[i].id;
98
+ }
99
+ return '';
100
+ }, [messages]);
101
+
102
+ useEffect(() => {
103
+ if (!sessionId || sessionId.startsWith('new-')) {
104
+ setIndexed(null);
105
+ return;
106
+ }
107
+ let active = true;
108
+ fetch(`/api/chat/${encodeURIComponent(sessionId)}/turns`)
109
+ .then((res) => (res.ok ? res.json() : null))
110
+ .then((data: { turns?: UserTurnRef[] } | null) => {
111
+ if (!active || !data || !Array.isArray(data.turns)) return;
112
+ setIndexed(data.turns);
113
+ })
114
+ .catch(() => {});
115
+ return () => {
116
+ active = false;
117
+ };
118
+ }, [sessionId, lastUserId]);
119
+
120
+ const turns = useMemo(() => {
121
+ // Until the index lands the mounted rows ARE the rail: a session that
122
+ // already shows turns never renders an empty rail.
123
+ if (!indexed) return mountedTurns;
124
+ const known = new Set(indexed.map((turn) => turn.id));
125
+ // Rows the index does not carry yet (a live send, a turn committed after
126
+ // the fetch) are already mounted, so they belong on the rail too.
127
+ const extra = mountedTurns.filter((turn) => !known.has(turn.id));
128
+ return extra.length > 0 ? [...indexed, ...extra] : indexed;
129
+ }, [indexed, mountedTurns]);
130
+
131
+ const jump = async (id: string, index: number): Promise<boolean> => {
132
+ // A row with no committed position is mounted by definition, and so is one
133
+ // inside the loaded window — both only need the scroll below.
134
+ if (index < 0) {
135
+ scrollToRow(id);
136
+ return true;
137
+ }
138
+ setJumping(true);
139
+ try {
140
+ const reached = await jumpToTurn(id, index);
141
+ if (reached) scrollToRow(id);
142
+ return reached;
143
+ } finally {
144
+ setJumping(false);
145
+ }
146
+ };
147
+
148
+ return { turns, jumping, jumpToTurn: jump };
149
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Browser file actions shared by the editor surfaces. `saveBlob` is the one
8
+ * place that builds and clicks the download anchor, so the text path (a Blob
9
+ * over the buffer) and the image path (a Blob over the fetched bytes) cannot
10
+ * drift apart — the image case has no text buffer at all, which is why it
11
+ * needs the URL variant.
12
+ */
13
+
14
+ /** Hand `blob` to the browser as a download named `name`. */
15
+ export function saveBlob(blob: Blob, name: string): void {
16
+ const url = URL.createObjectURL(blob);
17
+ const anchor = document.createElement('a');
18
+ anchor.href = url;
19
+ anchor.download = name;
20
+ anchor.click();
21
+ URL.revokeObjectURL(url);
22
+ }
23
+
24
+ /**
25
+ * Download a URL's bytes under `name`. Used for images, whose content never
26
+ * reaches the editor buffer — the file is fetched at download time so a large
27
+ * picture is never held in memory just because its tab is open.
28
+ */
29
+ export async function downloadUrl(url: string, name: string): Promise<void> {
30
+ const res = await fetch(url);
31
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
32
+ saveBlob(await res.blob(), name);
33
+ }
34
+
35
+ /**
36
+ * Put an image on the clipboard as a picture, not as a filename. The async
37
+ * clipboard API is only reachable over HTTPS/localhost and requires the image
38
+ * MIME to be in the browser's clipboard allow-list here — both fail by
39
+ * rejecting, which is the caller's signal that nothing was copied.
40
+ */
41
+ export async function copyImageToClipboard(url: string): Promise<void> {
42
+ const res = await fetch(url);
43
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
44
+ const blob = await res.blob();
45
+ if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) {
46
+ throw new Error('Image clipboard unavailable');
47
+ }
48
+ await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
49
+ }
@@ -1,5 +1,8 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { getLanguageFromPath } from '@/shared/lib/code/syntax-highlight';
3
+ import { getImageMimeType } from '@/shared/lib/fs/file-kind';
4
+ import { buildFsRawUrl } from '@/shared/lib/fs/paths';
5
+ import { useFileTransfer } from '@/client/hooks/editor/use-file-transfer';
3
6
 
4
7
  export type FileEditorSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
5
8
 
@@ -37,6 +40,10 @@ export interface UseFileEditorResult {
37
40
  copied: boolean;
38
41
  isDirty: boolean;
39
42
  language: string;
43
+ /** True for raster images: the surface renders `imageUrl`, not `content`. */
44
+ isImage: boolean;
45
+ /** Raw-byte URL of the active image (cache-busted per revision), else null. */
46
+ imageUrl: string | null;
40
47
  saveNow: () => void;
41
48
  copy: () => void;
42
49
  download: () => void;
@@ -78,7 +85,6 @@ export function useFileEditor(
78
85
  const [isLoading, setIsLoading] = useState(false);
79
86
  const [loadError, setLoadError] = useState<string | null>(null);
80
87
  const [saveStatus, setSaveStatus] = useState<FileEditorSaveStatus>('idle');
81
- const [copied, setCopied] = useState(false);
82
88
  /** Bumped by `reset()` to re-run the read effect for the active file. */
83
89
  const [revision, setRevision] = useState(0);
84
90
 
@@ -86,6 +92,15 @@ export function useFileEditor(
86
92
  const content = target ? (contents[key] ?? target.content ?? '') : '';
87
93
  const isDirty = content !== (baselines[key] ?? content);
88
94
 
95
+ // Extension-keyed, matching the server's own classification: an image tab
96
+ // never reads a text buffer and never writes one back. A target without a
97
+ // path has no bytes to serve, and one that already carries inline `content`
98
+ // (an attachment chip) has its text in hand — both stay on the text path.
99
+ const imageTarget =
100
+ target?.path && target.content === undefined && getImageMimeType(target.path) ? target.path : null;
101
+ const isImage = imageTarget !== null;
102
+ const imageUrl = imageTarget ? `${buildFsRawUrl({ ...target, path: imageTarget })}&v=${revision}` : null;
103
+
89
104
  // Keys whose read has been requested or served, so a re-render cannot fire a
90
105
  // duplicate fetch while the first one is still in flight.
91
106
  const loadedRef = useRef<Set<string>>(new Set());
@@ -142,6 +157,15 @@ export function useFileEditor(
142
157
  if (loadedRef.current.has(fileKey)) return;
143
158
  loadedRef.current.add(fileKey);
144
159
 
160
+ // An image has no text to read: the surface points an <img> at the raw
161
+ // bytes. Fetching here would only decode them into replacement characters
162
+ // and paint mojibake, so the key is marked loaded and left bufferless.
163
+ if (isImage) {
164
+ setLoadError(null);
165
+ setIsLoading(false);
166
+ return;
167
+ }
168
+
145
169
  if (target.content !== undefined) {
146
170
  remember(fileKey, target.content);
147
171
  setLoadError(null);
@@ -207,7 +231,7 @@ export function useFileEditor(
207
231
  return () => {
208
232
  cancelled = true;
209
233
  };
210
- }, [key, target, fallback, reportLoadError, remember, revision]);
234
+ }, [key, target, fallback, reportLoadError, remember, revision, isImage]);
211
235
 
212
236
  useEffect(() => {
213
237
  if (saveStatus !== 'saved') return;
@@ -217,7 +241,7 @@ export function useFileEditor(
217
241
 
218
242
  const onChange = useCallback(
219
243
  (next: string) => {
220
- if (!target) return;
244
+ if (!target || isImage) return;
221
245
  const fileKey = targetKey(target);
222
246
  setContents((prev) => ({ ...prev, [fileKey]: next }));
223
247
  if (!target.path) return;
@@ -228,11 +252,11 @@ export function useFileEditor(
228
252
  }, 800);
229
253
  pendingRef.current = { timer, file: target, content: next };
230
254
  },
231
- [target],
255
+ [target, isImage],
232
256
  );
233
257
 
234
258
  const saveNow = useCallback(() => {
235
- if (!target) return;
259
+ if (!target || isImage) return;
236
260
  const text = contents[targetKey(target)];
237
261
  if (text === undefined) return;
238
262
  if (pendingRef.current) {
@@ -240,29 +264,14 @@ export function useFileEditor(
240
264
  pendingRef.current = null;
241
265
  }
242
266
  void persistRef.current(target, text);
243
- }, [target, contents]);
244
-
245
- const copy = useCallback(() => {
246
- if (!target || !navigator.clipboard) return;
247
- navigator.clipboard
248
- .writeText(contents[targetKey(target)] ?? '')
249
- .then(() => {
250
- setCopied(true);
251
- window.setTimeout(() => setCopied(false), 1500);
252
- })
253
- .catch(() => {});
254
- }, [target, contents]);
267
+ }, [target, contents, isImage]);
255
268
 
256
- const download = useCallback(() => {
257
- if (!target) return;
258
- const blob = new Blob([contents[targetKey(target)] ?? ''], { type: downloadMimeType });
259
- const url = URL.createObjectURL(blob);
260
- const a = document.createElement('a');
261
- a.href = url;
262
- a.download = target.name;
263
- a.click();
264
- URL.revokeObjectURL(url);
265
- }, [target, contents, downloadMimeType]);
269
+ const { copied, copy, download } = useFileTransfer({
270
+ target,
271
+ content,
272
+ imageUrl,
273
+ downloadMimeType,
274
+ });
266
275
 
267
276
  const reset = useCallback(() => {
268
277
  // Invalidate every key, then let the read effect refetch the active one.
@@ -309,6 +318,8 @@ export function useFileEditor(
309
318
  copied,
310
319
  isDirty,
311
320
  language: target ? getLanguageFromPath(target.name) : 'javascript',
321
+ isImage,
322
+ imageUrl,
312
323
  saveNow,
313
324
  copy,
314
325
  download,
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Getting bytes *out* of an editor tab: copy to the clipboard and download.
8
+ *
9
+ * Split from `useFileEditor` because the two paths genuinely differ by kind —
10
+ * text is copied from the in-memory buffer and written to a Blob, an image has
11
+ * no buffer at all and is fetched at the moment of the action — and because
12
+ * that difference was pushing the editor hook past the file ceiling.
13
+ */
14
+
15
+ import { useCallback, useState } from 'preact/hooks';
16
+ import { copyImageToClipboard, downloadUrl, saveBlob } from '@/client/hooks/editor/file-actions';
17
+
18
+ interface FileTransferTarget {
19
+ name: string;
20
+ path?: string;
21
+ }
22
+
23
+ interface UseFileTransferOptions {
24
+ /** The active file, or null when the surface has no tab. */
25
+ target: FileTransferTarget | null;
26
+ /** Current text buffer for `target`; ignored for images. */
27
+ content: string;
28
+ /** Raw-byte URL of the active image, else null. */
29
+ imageUrl: string | null;
30
+ /** MIME type used for the text Blob. */
31
+ downloadMimeType: string;
32
+ }
33
+
34
+ export interface UseFileTransferResult {
35
+ /** True for the 1.5s after a successful copy, so the button can confirm it. */
36
+ copied: boolean;
37
+ copy: () => void;
38
+ download: () => void;
39
+ }
40
+
41
+ export function useFileTransfer({
42
+ target,
43
+ content,
44
+ imageUrl,
45
+ downloadMimeType,
46
+ }: UseFileTransferOptions): UseFileTransferResult {
47
+ const [copied, setCopied] = useState(false);
48
+
49
+ /** Confirmation flash shared by both copy paths. */
50
+ const flashCopied = useCallback(() => {
51
+ setCopied(true);
52
+ window.setTimeout(() => setCopied(false), 1500);
53
+ }, []);
54
+
55
+ const copy = useCallback(() => {
56
+ if (!target) return;
57
+ // An image goes to the clipboard as a picture; a text file as its text.
58
+ if (imageUrl) {
59
+ void copyImageToClipboard(imageUrl).then(flashCopied).catch(() => {});
60
+ return;
61
+ }
62
+ if (!navigator.clipboard) return;
63
+ navigator.clipboard.writeText(content).then(flashCopied).catch(() => {});
64
+ }, [target, content, imageUrl, flashCopied]);
65
+
66
+ const download = useCallback(() => {
67
+ if (!target) return;
68
+ // An image's bytes are fetched at download time, so a large picture costs
69
+ // nothing while its tab merely sits open.
70
+ if (imageUrl) {
71
+ void downloadUrl(imageUrl, target.name).catch(() => {});
72
+ return;
73
+ }
74
+ saveBlob(new Blob([content], { type: downloadMimeType }), target.name);
75
+ }, [target, content, downloadMimeType, imageUrl]);
76
+
77
+ return { copied, copy, download };
78
+ }
@@ -0,0 +1,33 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import type { RefObject } from 'preact';
3
+
4
+ /**
5
+ * Measure an element's content width and keep it current.
6
+ *
7
+ * Panel widths are stored as a share of the group's available area, so the
8
+ * group has to report that area before any fraction can become pixels. Returns
9
+ * `null` until the first measurement — callers must treat that as "unknown",
10
+ * not as zero, or a panel would collapse on its first render.
11
+ *
12
+ * `ResizeObserver` is not optional here: the width changes on a window resize,
13
+ * on a devtools dock, and on a sibling panel being toggled, and none of those
14
+ * are `window` events.
15
+ */
16
+ export function useAvailableWidth(ref: RefObject<HTMLElement | null>): number | null {
17
+ const [available, setAvailable] = useState<number | null>(null);
18
+
19
+ useEffect(() => {
20
+ const element = ref.current;
21
+ if (!element || typeof ResizeObserver === 'undefined') return;
22
+
23
+ const observer = new ResizeObserver(() => {
24
+ setAvailable(element.clientWidth || null);
25
+ });
26
+ observer.observe(element);
27
+ setAvailable(element.clientWidth || null);
28
+
29
+ return () => observer.disconnect();
30
+ }, [ref]);
31
+
32
+ return available;
33
+ }
@@ -1,59 +1,56 @@
1
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
- import type { RefObject } from 'preact/compat';
3
- import { mergePanelWidths, normalizePanelWidths, type PanelWidths } from '@/shared/lib/workspace/panel-widths';
1
+ import { useCallback, useMemo } from 'preact/hooks';
2
+ import {
3
+ mergePanelWidths,
4
+ normalizePanelWidths,
5
+ type PanelWidths,
6
+ } from '@/shared/lib/workspace/panel-widths';
4
7
  import type { RightPanelType } from '@/shared/lib/workspace/right-panels';
5
- import { writeSetting } from '@/shared/lib/settings/client';
8
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
6
9
 
7
- /** A drag commits once, after the pointer is released; this only coalesces. */
8
- const PERSIST_DEBOUNCE_MS = 500;
10
+ /** Session-state key holding this session's own per-panel widths. */
11
+ const PANEL_WIDTHS_KEY = 'layout.panelWidths';
9
12
 
10
13
  interface PanelWidthsResult {
11
14
  widths: PanelWidths;
12
- /** Live widths, for imperative restores that cannot wait on a re-render. */
13
- widthsRef: RefObject<PanelWidths>;
14
- /** Fold measured panel widths in and persist the merged map. */
15
+ /** Fold measured panel widths into this session's map. */
15
16
  commitWidths: (patch: PanelWidths) => void;
16
17
  }
17
18
 
18
19
  /**
19
- * Owns the desktop layout's per-panel width map: restored from
20
- * `app_settings.desktopLayoutSizes`, kept in a ref for resize handlers, and
21
- * written back (debounced) whenever a panel is resized. One writer for both
22
- * resizable groups, so neither clobbers the other's widths.
20
+ * Owns the desktop layout's per-panel width map.
21
+ *
22
+ * Widths are **per session**: they live in `session_ui_state` under
23
+ * `layout.panelWidths`, next to the rest of a session's layout state
24
+ * (`layout.activeRightPanel`, `layout.showRightPanel`, `layout.openedFiles`), so
25
+ * switching sessions brings back the layout that session was left in.
26
+ *
27
+ * `app_settings.desktopLayoutSizes` is the **seed**: a session that has never
28
+ * been resized reads it, which is what keeps an existing user's layout across
29
+ * the upgrade instead of resetting everyone to the defaults. Once a panel is
30
+ * dragged, the session stores its own map and the seed is no longer consulted
31
+ * for that session.
32
+ *
33
+ * There is deliberately no px→fraction rewrite pass here. Converting a legacy
34
+ * pixel value is done when a width is *read* (`resolvePanelWidth`), because a
35
+ * write would have to run before this session's own blob has loaded and would
36
+ * therefore overwrite it with the seed.
23
37
  */
24
38
  export function usePanelWidths(
25
39
  appSettings: Record<string, any>,
26
40
  legacyView: RightPanelType,
27
41
  ): PanelWidthsResult {
28
- const [widths, setWidths] = useState<PanelWidths>(() =>
29
- normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
42
+ const seed = useMemo(
43
+ () => normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
44
+ [appSettings.desktopLayoutSizes, legacyView],
30
45
  );
31
- const widthsRef = useRef<PanelWidths>(widths);
32
- // Set while a debounced write is pending, which is also the flush condition.
33
- const timerRef = useRef<number | undefined>(undefined);
34
-
35
- const saveNow = useCallback(() => {
36
- clearTimeout(timerRef.current);
37
- timerRef.current = undefined;
38
- writeSetting('desktopLayoutSizes', widthsRef.current);
39
- }, []);
46
+ const [widths, setWidths] = useSessionState<PanelWidths>(PANEL_WIDTHS_KEY, seed);
40
47
 
41
48
  const commitWidths = useCallback(
42
49
  (patch: PanelWidths) => {
43
- const next = mergePanelWidths(widthsRef.current, patch);
44
- widthsRef.current = next;
45
- setWidths(next);
46
- clearTimeout(timerRef.current);
47
- timerRef.current = window.setTimeout(saveNow, PERSIST_DEBOUNCE_MS);
50
+ setWidths((prev) => mergePanelWidths(prev, patch));
48
51
  },
49
- [saveNow],
52
+ [setWidths],
50
53
  );
51
54
 
52
- // The mobile layout unmounts this hook; flush instead of dropping the width
53
- // the user just dragged.
54
- useEffect(() => () => {
55
- if (timerRef.current !== undefined) saveNow();
56
- }, [saveNow]);
57
-
58
- return { widths, widthsRef, commitWidths };
55
+ return { widths, commitWidths };
59
56
  }
@@ -358,6 +358,28 @@
358
358
  max-width: none !important;
359
359
  }
360
360
 
361
+ /* Image viewer surface (`common/image-viewer/`): the stage is the pan surface,
362
+ the canvas carries the zoom/pan transform so the picture keeps its natural
363
+ pixel size and never reflows while it is being zoomed. The checkerboard sits
364
+ behind the canvas so a transparent PNG reads as transparent instead of
365
+ looking like a white file. */
366
+ .image-stage {
367
+ touch-action: none;
368
+ overscroll-behavior: contain;
369
+ background-color: var(--theme-canvas);
370
+ background-image:
371
+ linear-gradient(45deg, color-mix(in srgb, var(--theme-ink) 6%, transparent) 25%, transparent 25%),
372
+ linear-gradient(-45deg, color-mix(in srgb, var(--theme-ink) 6%, transparent) 25%, transparent 25%),
373
+ linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--theme-ink) 6%, transparent) 75%),
374
+ linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--theme-ink) 6%, transparent) 75%);
375
+ background-size: 16px 16px;
376
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0;
377
+ }
378
+ .image-canvas {
379
+ transform-origin: center center;
380
+ will-change: transform;
381
+ }
382
+
361
383
  /* PWA Window Controls Overlay and Native Titlebar Styling */
362
384
  .titlebar-drag-region {
363
385
  -webkit-app-region: drag;