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
@@ -5,37 +5,63 @@
5
5
 
6
6
  import { describe, expect, test } from 'bun:test';
7
7
 
8
- import { mergePanelWidths, normalizePanelWidths } from '@/shared/lib/workspace/panel-widths';
8
+ import {
9
+ HANDLE_WIDTH,
10
+ MIN_CHAT_PANEL_WIDTH,
11
+ mergePanelWidths,
12
+ normalizePanelWidths,
13
+ resolvePanelWidth,
14
+ } from '@/shared/lib/workspace/panel-widths';
9
15
 
10
16
  describe('normalizePanelWidths', () => {
11
17
  test('keeps every panel slot from a current blob', () => {
12
18
  const raw = {
13
19
  left: 342,
14
- editor: 620,
15
- diff: 900,
16
- right: { files: 300, browser: 804 },
20
+ editor: { px: 620, fraction: 0.54 },
21
+ diff: { fraction: 0.6 },
22
+ right: { files: { px: 300, fraction: 0.26 }, browser: { px: 804 } },
17
23
  };
18
24
 
19
25
  expect(normalizePanelWidths(raw, 'files')).toEqual(raw);
20
26
  });
21
27
 
28
+ test('reads a bare number as the px-only shape and keeps it for conversion', () => {
29
+ // Blobs written before fractions existed: the pixel width must survive so
30
+ // the layout can convert it once the group's area is known.
31
+ const widths = normalizePanelWidths({ left: 342, editor: 620, right: { files: 300 } }, 'files');
32
+
33
+ expect(widths).toEqual({ left: 342, editor: { px: 620 }, right: { files: { px: 300 } } });
34
+ });
35
+
22
36
  test('adopts a legacy shared right width into the view that was open', () => {
23
37
  const widths = normalizePanelWidths({ left: 342, editor: 620, right: 1091 }, 'browser');
24
38
 
25
- expect(widths.right).toEqual({ browser: 1091 });
39
+ expect(widths.right).toEqual({ browser: { px: 1091 } });
26
40
  expect(widths.left).toBe(342);
27
- expect(widths.editor).toBe(620);
41
+ expect(widths.editor).toEqual({ px: 620 });
42
+ });
43
+
44
+ test('drops an out-of-range fraction instead of trusting it', () => {
45
+ // A fraction is a share of the area, so 0 and >1 are meaningless; the px
46
+ // fallback in the same slot survives, and a slot left with neither unit
47
+ // disappears rather than being kept as an empty object.
48
+ const widths = normalizePanelWidths(
49
+ { editor: { px: 620, fraction: 1.25 }, diff: { fraction: 0 }, right: { files: { fraction: -0.5 } } },
50
+ 'files',
51
+ );
52
+
53
+ expect(widths).toEqual({ editor: { px: 620 } });
28
54
  });
29
55
 
30
56
  test('drops unusable entries instead of trusting the blob', () => {
31
57
  // `chat` is an obsolete slot — the chat column is the filler now — and a
32
58
  // blob written before that must not resurrect it.
33
59
  const widths = normalizePanelWidths(
34
- { left: -10, chat: 700, editor: 620.4, diff: null, right: { files: 0, bogus: 300, git: 260 } },
60
+ { left: -10, chat: 700, editor: { px: 620.4 }, diff: null, right: { files: { px: 0 }, bogus: 300, git: { px: 260 } } },
35
61
  'files',
36
62
  );
37
63
 
38
- expect(widths).toEqual({ editor: 620, right: { git: 260 } });
64
+ expect(widths).toEqual({ editor: { px: 620 }, right: { git: { px: 260 } } });
39
65
  });
40
66
 
41
67
  test('tolerates a missing or malformed blob', () => {
@@ -48,25 +74,94 @@ describe('mergePanelWidths', () => {
48
74
  test('merging one view keeps every other remembered width', () => {
49
75
  const stored = {
50
76
  left: 342,
51
- editor: 620,
52
- diff: 900,
53
- right: { files: 300, git: 260 },
77
+ editor: { px: 620, fraction: 0.54 },
78
+ diff: { fraction: 0.6 },
79
+ right: { files: { px: 300, fraction: 0.26 }, git: { px: 260 } },
54
80
  };
55
81
 
56
- const merged = mergePanelWidths(stored, { right: { browser: 804 } });
82
+ const merged = mergePanelWidths(stored, { right: { browser: { px: 804, fraction: 0.7 } } });
57
83
 
58
- expect(merged.right).toEqual({ files: 300, git: 260, browser: 804 });
84
+ expect(merged.right).toEqual({
85
+ files: { px: 300, fraction: 0.26 },
86
+ git: { px: 260 },
87
+ browser: { px: 804, fraction: 0.7 },
88
+ });
59
89
  expect(merged.left).toBe(342);
60
- expect(merged.editor).toBe(620);
61
- expect(merged.diff).toBe(900);
90
+ expect(merged.editor).toEqual({ px: 620, fraction: 0.54 });
91
+ expect(merged.diff).toEqual({ fraction: 0.6 });
92
+ });
93
+
94
+ test('a patch adds a unit without discarding the one already stored', () => {
95
+ // A drag reports px + fraction together, but the conversion pass reports a
96
+ // fraction alone; it must not erase the px fallback it was derived from.
97
+ const merged = mergePanelWidths({ editor: { px: 620 } }, { editor: { fraction: 0.54 } });
98
+
99
+ expect(merged.editor).toEqual({ px: 620, fraction: 0.54 });
62
100
  });
63
101
 
64
102
  test('a resized panel replaces only its own slot', () => {
65
103
  const merged = mergePanelWidths(
66
- { left: 300, editor: 620, diff: 900, right: { files: 300 } },
67
- { editor: 480 },
104
+ { left: 300, editor: { px: 620 }, diff: { px: 900 }, right: { files: { px: 300 } } },
105
+ { editor: { px: 480, fraction: 0.4 } },
68
106
  );
69
107
 
70
- expect(merged).toEqual({ left: 300, editor: 480, diff: 900, right: { files: 300 } });
108
+ expect(merged).toEqual({
109
+ left: 300,
110
+ editor: { px: 480, fraction: 0.4 },
111
+ diff: { px: 900 },
112
+ right: { files: { px: 300 } },
113
+ });
114
+ });
115
+ });
116
+
117
+ describe('resolvePanelWidth', () => {
118
+ const base = {
119
+ defaultFraction: 0.6,
120
+ defaultPx: 600,
121
+ min: 320,
122
+ siblingMin: 0,
123
+ handleCount: 0,
124
+ };
125
+
126
+ test('opens at its default share of the measured area', () => {
127
+ expect(resolvePanelWidth({ ...base, available: 1000 })).toBe(600);
128
+ });
129
+
130
+ test('falls back to the pixel width before the area is known', () => {
131
+ // Not zero: a panel whose area has not been measured yet must still render
132
+ // at the width it is supposed to open at.
133
+ expect(resolvePanelWidth({ ...base, available: null })).toBe(600);
134
+ expect(resolvePanelWidth({ ...base, available: null, stored: { px: 512 } })).toBe(512);
135
+ });
136
+
137
+ test('a px-only value holds its pixel width; only a fraction follows the area', () => {
138
+ // The conversion is an identity (`px / available * available`), which is
139
+ // the point: a pixel value someone chose is honoured as written rather than
140
+ // being rescaled behind their back. It stops following the window only
141
+ // until that slot is dragged once, which stores a fraction alongside it.
142
+ expect(resolvePanelWidth({ ...base, available: 1000, stored: { px: 480 } })).toBe(480);
143
+ expect(resolvePanelWidth({ ...base, available: 2000, stored: { px: 480 } })).toBe(480);
144
+ // With both units stored, the fraction is what the panel follows.
145
+ expect(resolvePanelWidth({ ...base, available: 2000, stored: { px: 480, fraction: 0.24 } })).toBe(480);
146
+ expect(resolvePanelWidth({ ...base, available: 2000, stored: { px: 480, fraction: 0.3 } })).toBe(600);
147
+ });
148
+
149
+ test('a stored fraction beats the default share', () => {
150
+ // 0.45 of 1000 is 450: above the 320 floor and below the 0.6 default, so
151
+ // the result can only come from the stored fraction.
152
+ expect(resolvePanelWidth({ ...base, available: 1000, stored: { px: 480, fraction: 0.45 } })).toBe(450);
153
+ });
154
+
155
+ test('the ceiling leaves the chat its floor, the siblings theirs, and the handles their width', () => {
156
+ const available = 2000;
157
+ const expected = available - MIN_CHAT_PANEL_WIDTH - 320 - 2 * HANDLE_WIDTH;
158
+
159
+ expect(resolvePanelWidth({ ...base, available, siblingMin: 320, handleCount: 2, stored: { fraction: 0.9 } })).toBe(expected);
160
+ });
161
+
162
+ test('the floor wins over a ceiling squeezed below it', () => {
163
+ // A window too narrow for the chat floor, a sibling and this panel: the
164
+ // panel keeps its minimum and the row clips, rather than collapsing.
165
+ expect(resolvePanelWidth({ ...base, available: 400, siblingMin: 320, handleCount: 2 })).toBe(320);
71
166
  });
72
167
  });
@@ -8,75 +8,192 @@
8
8
  * like a reset. The chat column is the group's filler — flexbox sizes it — so
9
9
  * it has no remembered width, only a floor it may not be pushed below.
10
10
  *
11
- * Sizes are pixels — the unit the panel constraints are written in — and are
12
- * persisted as `app_settings.desktopLayoutSizes`.
11
+ * Two units, deliberately:
12
+ *
13
+ * - The **sidebar** is pixels. It holds a session list, which does not get more
14
+ * useful on a wider monitor, and OpenChamber's sidebar is pixels too.
15
+ * - The **editor and right panels** are a *share of the group's available
16
+ * area*. A pixel width is only right on the display it was dragged on; a
17
+ * fraction follows a window resize. A remembered pixel value is kept
18
+ * alongside as the fallback for the window before the area is measured and
19
+ * for blobs written before the fraction existed.
20
+ *
21
+ * Sizes persist as `app_settings.desktopLayoutSizes`.
13
22
  */
14
23
  import { isRightPanelType, type RightPanelType } from '@/shared/lib/workspace/right-panels';
15
24
 
16
25
  /** The editor panel keeps a separate width while a diff tab is active. */
17
26
  export type EditorWidthMode = 'editor' | 'diff';
18
27
 
28
+ /**
29
+ * A panel width in both units. `fraction` is authoritative once the group's
30
+ * area is known; `px` is what the panel falls back to before then, and what a
31
+ * blob written before fractions existed carries.
32
+ */
33
+ export interface PanelWidth {
34
+ px?: number;
35
+ fraction?: number;
36
+ }
37
+
19
38
  export interface PanelWidths {
20
- /** Session sidebar. */
39
+ /** Session sidebar. Pixels only — it does not scale with the window. */
21
40
  left?: number;
22
41
  /** Editor panel while a source file tab is active. */
23
- editor?: number;
42
+ editor?: PanelWidth;
24
43
  /** Editor panel while a diff tab is active. */
25
- diff?: number;
44
+ diff?: PanelWidth;
26
45
  /** Right panel, keyed by the activity-bar view it currently shows. */
27
- right?: Partial<Record<RightPanelType, number>>;
46
+ right?: Partial<Record<RightPanelType, PanelWidth>>;
28
47
  }
29
48
 
30
- /** Fallbacks for panels that have never been resized. */
31
- export const DEFAULT_PANEL_WIDTHS: Record<EditorWidthMode | 'left', number> = {
32
- left: 268,
49
+ /**
50
+ * Fraction each editor mode opens at, and the px width used before the group's
51
+ * area is known. The diff fraction matches OpenChamber's `diff` surface (3/5);
52
+ * its px fallback stays wider than the source editor's because `SplitView`
53
+ * states a 700px floor of its own, so anything below it scrolls sideways.
54
+ */
55
+ export const DEFAULT_EDITOR_FRACTIONS: Record<EditorWidthMode, number> = {
56
+ editor: 0.6,
57
+ diff: 0.6,
58
+ };
59
+
60
+ export const DEFAULT_EDITOR_WIDTHS: Record<EditorWidthMode, number> = {
33
61
  editor: 600,
34
- // Side-by-side diffs need both halves readable at once: `SplitView` states a
35
- // 700px floor of its own, so anything below it scrolls sideways on open.
36
62
  diff: 720,
37
63
  };
38
64
 
65
+ /** Fallback for the sidebar, which has no fraction. */
66
+ export const DEFAULT_LEFT_PANEL_WIDTH = 280;
67
+
39
68
  /**
40
69
  * Panel floors and ceilings. The sidebar ceiling and the chat floor are a pair:
41
70
  * `MIN_CHAT_PANEL_WIDTH` is what stops the workspace stack from eating the
42
- * conversation, and it is also why the sidebar cannot open past 520 and still
43
- * leave the editor (300) and a browser view (320) room on a 1440px display.
71
+ * conversation, and it is also why the sidebar cannot open past 500 and still
72
+ * leave the editor (320) and a browser view (320) room on a 1440px display.
73
+ *
74
+ * `MIN_EDITOR_PANEL_WIDTH` matches the shared right-panel floor so the two
75
+ * panels cannot disagree about the narrowest width the same content is shown
76
+ * at.
44
77
  */
45
- export const MIN_LEFT_PANEL_WIDTH = 200;
46
- export const MAX_LEFT_PANEL_WIDTH = 520;
47
- export const MIN_CHAT_PANEL_WIDTH = 420;
48
- export const MIN_EDITOR_PANEL_WIDTH = 300;
49
- export const MAX_EDITOR_PANEL_WIDTH = 1200;
78
+ export const MIN_LEFT_PANEL_WIDTH = 264;
79
+ export const MAX_LEFT_PANEL_WIDTH = 500;
80
+ export const MIN_CHAT_PANEL_WIDTH = 400;
81
+ export const MIN_EDITOR_PANEL_WIDTH = 320;
82
+
83
+ /** Draggable separator width in px (`w-[3px]` in `ResizeHandle`). */
84
+ export const HANDLE_WIDTH = 3;
85
+
86
+ /** How much of a drag may re-apply the real width, in ms. */
87
+ export const RESIZE_FOLLOW_INTERVAL_MS = 100;
88
+
89
+ export interface ResolveWidthArgs {
90
+ /** Remembered width for this slot, if the user ever resized it. */
91
+ stored?: PanelWidth;
92
+ /** Fraction this slot opens at when nothing was remembered. */
93
+ defaultFraction: number;
94
+ /** px this slot opens at before the area is known. */
95
+ defaultPx: number;
96
+ /** Measured area of the group, or null before the observer reports. */
97
+ available: number | null;
98
+ min: number;
99
+ /** Floors the other fixed panels in the same group must keep. */
100
+ siblingMin: number;
101
+ /** Separators in the group, which also consume width. */
102
+ handleCount: number;
103
+ }
50
104
 
51
- function pixelWidth(value: unknown): number | undefined {
105
+ /**
106
+ * Resolve one panel's width in px.
107
+ *
108
+ * The ceiling is dynamic — `available − chat floor − the other panels' floors`
109
+ * — rather than a fixed number, because a fixed one either wastes a large
110
+ * monitor or eats the chat on a small one. Both the editor and the right panel
111
+ * compete for the same budget, so whichever renders first takes its share and
112
+ * the other gets what is left; the caller passes `siblingMin` to say which.
113
+ *
114
+ * Without a measured area there is no budget to divide, so the stored or
115
+ * default pixel width is used unchanged.
116
+ */
117
+ export function resolvePanelWidth(args: ResolveWidthArgs): number {
118
+ const { stored, defaultFraction, defaultPx, available, min, siblingMin, handleCount } = args;
119
+
120
+ if (available === null || available <= 0) return Math.max(min, Math.round(stored?.px ?? defaultPx));
121
+
122
+ const ceiling = Math.max(
123
+ min,
124
+ Math.floor(available - MIN_CHAT_PANEL_WIDTH - siblingMin - handleCount * HANDLE_WIDTH),
125
+ );
126
+
127
+ // A remembered fraction wins. A px-only value is honoured as written — the
128
+ // conversion below is an identity, deliberately: a pixel width someone chose
129
+ // is not silently rescaled, and it starts following the window only once that
130
+ // slot is dragged, which stores a fraction beside it. Reading rather than
131
+ // rewriting the stored blob is also what keeps a session's own widths from
132
+ // being clobbered by the global seed before that session's blob has loaded.
133
+ const fraction = stored?.fraction
134
+ ?? (stored?.px != null ? Math.min(1, stored.px / available) : defaultFraction);
135
+ const wanted = Math.round(fraction * available);
136
+
137
+ return Math.min(Math.max(wanted, min), ceiling);
138
+ }
139
+
140
+ function panelWidth(value: unknown): PanelWidth | undefined {
141
+ if (typeof value === 'number') {
142
+ return Number.isFinite(value) && value > 0 ? { px: Math.round(value) } : undefined;
143
+ }
144
+ if (!value || typeof value !== 'object') return undefined;
145
+
146
+ const source = value as Record<string, unknown>;
147
+ const px = Number.isFinite(source.px) && (source.px as number) > 0 ? Math.round(source.px as number) : undefined;
148
+ const fraction = Number.isFinite(source.fraction) && (source.fraction as number) > 0 && (source.fraction as number) <= 1
149
+ ? (source.fraction as number)
150
+ : undefined;
151
+
152
+ if (px === undefined && fraction === undefined) return undefined;
153
+ return { ...(px !== undefined ? { px } : {}), ...(fraction !== undefined ? { fraction } : {}) };
154
+ }
155
+
156
+ function sidebarWidth(value: unknown): number | undefined {
52
157
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
53
158
  }
54
159
 
55
160
  /**
56
- * Coerce a stored blob into the current shape. Blobs written before per-view
57
- * widths held the right panel as a single number shared by every view; that
58
- * number belongs to whichever view was open when it was written, so it seeds
59
- * `legacyView` alone instead of being copied onto all eight.
161
+ * Coerce a stored blob into the current shape.
162
+ *
163
+ * Three older shapes are accepted, because a user's layout must survive the
164
+ * upgrade rather than silently reset:
165
+ *
166
+ * - a bare number is the px-only shape this app shipped before fractions;
167
+ * - a single number under `right` is older still, holding one width shared by
168
+ * every view — it belongs to whichever view was open when it was written, so
169
+ * it seeds `legacyView` alone instead of being copied onto all eight;
170
+ * - an unknown `right` key is dropped rather than trusted.
171
+ *
172
+ * Nothing is converted to a fraction here: this function is pure and the
173
+ * conversion needs the group's measured area. The layout performs it once the
174
+ * area is known (see `usePanelWidths`).
60
175
  */
61
176
  export function normalizePanelWidths(raw: unknown, legacyView: RightPanelType): PanelWidths {
62
177
  if (!raw || typeof raw !== 'object') return {};
63
178
  const source = raw as Record<string, unknown>;
64
179
 
65
180
  const widths: PanelWidths = {};
66
- for (const key of ['left', 'editor', 'diff'] as const) {
67
- const value = pixelWidth(source[key]);
181
+ const left = sidebarWidth(source.left);
182
+ if (left !== undefined) widths.left = left;
183
+ for (const key of ['editor', 'diff'] as const) {
184
+ const value = panelWidth(source[key]);
68
185
  if (value !== undefined) widths[key] = value;
69
186
  }
70
187
 
71
- const right: Partial<Record<RightPanelType, number>> = {};
188
+ const right: Partial<Record<RightPanelType, PanelWidth>> = {};
72
189
  if (source.right && typeof source.right === 'object') {
73
190
  for (const [view, value] of Object.entries(source.right as Record<string, unknown>)) {
74
191
  if (!isRightPanelType(view)) continue;
75
- const size = pixelWidth(value);
192
+ const size = panelWidth(value);
76
193
  if (size !== undefined) right[view] = size;
77
194
  }
78
195
  } else {
79
- const legacy = pixelWidth(source.right);
196
+ const legacy = panelWidth(source.right);
80
197
  if (legacy !== undefined) right[legacyView] = legacy;
81
198
  }
82
199
  if (Object.keys(right).length > 0) widths.right = right;
@@ -84,9 +201,23 @@ export function normalizePanelWidths(raw: unknown, legacyView: RightPanelType):
84
201
  return widths;
85
202
  }
86
203
 
87
- /** Fold a freshly measured patch over the stored widths (one level deep for `right`). */
204
+ /**
205
+ * Fold a freshly measured patch over the stored widths (one level deep for
206
+ * `right`, and within a slot for its two units — a fraction patch must not
207
+ * discard the px fallback it was derived from).
208
+ */
88
209
  export function mergePanelWidths(current: PanelWidths, patch: PanelWidths): PanelWidths {
89
210
  const next: PanelWidths = { ...current, ...patch };
90
- if (patch.right) next.right = { ...current.right, ...patch.right };
211
+ if (patch.right) {
212
+ const right: Partial<Record<RightPanelType, PanelWidth>> = { ...current.right };
213
+ for (const [view, value] of Object.entries(patch.right)) {
214
+ if (!isRightPanelType(view) || !value) continue;
215
+ right[view] = { ...current.right?.[view], ...value };
216
+ }
217
+ next.right = right;
218
+ }
219
+ for (const key of ['editor', 'diff'] as const) {
220
+ if (patch[key]) next[key] = { ...current[key], ...patch[key] };
221
+ }
91
222
  return next;
92
223
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Right developer panel catalog: the activity-bar views, the width each one
3
- * opens at, and the floor at which its content starts to clip.
2
+ * Right developer panel catalog: the activity-bar views, the share of the
3
+ * group's available area each one opens at, and the floor at which its content
4
+ * clips.
4
5
  *
5
6
  * The list lives here rather than in the activity bar component so the layout
6
7
  * can key one remembered width per view (`panel-widths.ts`) and validate a
@@ -21,36 +22,74 @@ export const RIGHT_PANEL_TYPES = [
21
22
 
22
23
  export type RightPanelType = (typeof RIGHT_PANEL_TYPES)[number];
23
24
 
24
- /** Width (px) a view opens at the first time it is shown. */
25
+ /**
26
+ * Share of the group's available area a view takes the first time it is shown.
27
+ *
28
+ * A fraction rather than a pixel width is what lets a panel follow a window
29
+ * resize instead of holding a number that was only right on the display it was
30
+ * dragged on. Where OpenChamber ships the same surface its `defaultWidthFraction`
31
+ * is reused verbatim; the rest are derived.
32
+ *
33
+ * - `files` is the file tree *alone*. OpenChamber's 3/5 `file` surface holds a
34
+ * tree **and** an editor column, so its fraction is not ours to borrow — their
35
+ * tree-only column is 200–480px, which is what this 0.22 approximates at a
36
+ * 1440-class viewport.
37
+ * - `search` matches `files`: both are single narrow columns of rows.
38
+ * - `usage` has no OpenChamber counterpart; 0.48 keeps the pixel default it
39
+ * shipped with.
40
+ * - `context`, `git`, `terminal` and both browsers take OpenChamber's values.
41
+ */
42
+ export const DEFAULT_RIGHT_PANEL_FRACTIONS: Record<RightPanelType, number> = {
43
+ context: 0.45,
44
+ files: 0.22,
45
+ search: 0.22,
46
+ git: 0.4,
47
+ // 80 columns at the terminal's 12px Fira Code (≈7.2px/char) need ≈600px.
48
+ terminal: 0.6,
49
+ 'user-browser': 0.45,
50
+ browser: 0.45,
51
+ usage: 0.48,
52
+ };
53
+
54
+ /**
55
+ * Width (px) a view opens at before the group's area has been measured. Kept at
56
+ * the values these views shipped with, so the first paint is unchanged and a
57
+ * window that never reports an area still opens sensibly.
58
+ */
25
59
  export const DEFAULT_RIGHT_PANEL_WIDTHS: Record<RightPanelType, number> = {
60
+ context: 536,
26
61
  files: 268,
27
62
  search: 268,
28
63
  git: 268,
29
- // 80 columns at the terminal's 12px Fira Code (≈7.2px/char) need ≈600px
30
- // before the scrollbar; the old 536 landed at ≈74 columns and wrapped.
31
64
  terminal: 640,
32
- context: 536,
33
65
  'user-browser': 804,
34
66
  browser: 804,
35
67
  usage: 536,
36
68
  };
37
69
 
38
- /** Minimum width (px) each view needs before its toolbar or list breaks. */
70
+ /**
71
+ * Minimum width (px) each view needs before its toolbar or list breaks.
72
+ *
73
+ * Per view, not one shared floor: a file tree reads fine at 200 while a shell
74
+ * does not, and OpenChamber draws the same line (its tree-only column floors at
75
+ * 200, its shared panel at 320). A uniform floor would either clip the tree or
76
+ * force every view to shell width.
77
+ */
39
78
  export const MIN_RIGHT_PANEL_WIDTHS: Record<RightPanelType, number> = {
40
79
  files: 200,
41
80
  search: 200,
81
+ // The commit graph is a fixed-width gutter plus a message column.
42
82
  git: 260,
43
- // A 200px terminal is ≈27 columns — below every shell's assumption; 320
44
- // holds a two-pane TUI or a git log with the graph.
83
+ // A 320px terminal is ≈27 columns — below every shell's assumption, but the
84
+ // point where a two-pane TUI or a git log with the graph still reads.
45
85
  terminal: 320,
86
+ // Telemetry charts and tables need room before they wrap.
46
87
  context: 420,
47
88
  'user-browser': 320,
48
89
  browser: 320,
49
90
  usage: 420,
50
91
  };
51
92
 
52
- export const MAX_RIGHT_PANEL_WIDTH = 1200;
53
-
54
93
  /** Guards a persisted or URL-provided view id before it keys a width. */
55
94
  export function isRightPanelType(value: unknown): value is RightPanelType {
56
95
  return typeof value === 'string' && (RIGHT_PANEL_TYPES as readonly string[]).includes(value);
@@ -12,6 +12,9 @@
12
12
  * Key namespace contract (dot-prefixed by owning panel):
13
13
  * - layout.activeRightPanel RightPanelType
14
14
  * - layout.showRightPanel boolean
15
+ * - layout.showLeftPanel boolean
16
+ * - layout.userToggledEditor boolean | null (null = follow whether a file is open)
17
+ * - layout.panelWidths PanelWidths (per-panel widths, seeded from app_settings.desktopLayoutSizes)
15
18
  * - layout.openedFiles editor file entries array
16
19
  * - layout.activeFileId number | null
17
20
  * - editor.previewMode Record<fileId, boolean>
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * BTW (side question) domain types.
8
+ *
9
+ * A BTW topic is an independent question plus its explicit follow-ups, run
10
+ * against the parent session's context but never appended to its transcript —
11
+ * the same contract omp's own `/btw` implements in the TUI. The server is the
12
+ * source of truth for a session's topics; the panel renders the state it is
13
+ * sent and streams answer text through `btw_delta` frames.
14
+ */
15
+
16
+ /** Lifecycle of one question/answer pair inside a topic. */
17
+ export type BtwTurnStatus = 'running' | 'complete' | 'cancelled' | 'failed' | 'interrupted';
18
+
19
+ /** Model a topic's side conversation runs on — the parent session's model,
20
+ * captured when the topic was created (omp's `/btw` uses the active model). */
21
+ export interface BtwModel {
22
+ provider: string;
23
+ id: string;
24
+ /** Display name reported by the running side session (`DeepSeek-V4.1-Flash`). */
25
+ name?: string;
26
+ }
27
+
28
+ /** One question and its answer inside a topic. */
29
+ export interface BtwTurn {
30
+ index: number;
31
+ question: string;
32
+ answer: string;
33
+ status: BtwTurnStatus;
34
+ createdAt: number;
35
+ updatedAt: number;
36
+ }
37
+
38
+ /** A side conversation scoped to one chamber session. */
39
+ export interface BtwTopic {
40
+ id: string;
41
+ sessionId: string;
42
+ /** The first question, used as the topic's label in history. */
43
+ title: string;
44
+ createdAt: number;
45
+ updatedAt: number;
46
+ model?: BtwModel;
47
+ /** Set once the topic was promoted into the chat as a session of its own. */
48
+ promotedSessionId?: string;
49
+ turns: BtwTurn[];
50
+ }
51
+
52
+ /** A session's whole BTW view. */
53
+ export interface BtwState {
54
+ topics: BtwTopic[];
55
+ /** Topic with a question in flight; at most one per session, because a
56
+ * session runs one side conversation at a time. */
57
+ runningTopicId: string | null;
58
+ }
59
+
60
+ /** Server → client frames on `/api/btw/:sessionId/*` (WS and SSE alike). */
61
+ export type BtwFrame =
62
+ | { type: 'connected'; sessionId: string }
63
+ | { type: 'btw_state'; state: BtwState }
64
+ | { type: 'btw_delta'; topicId: string; turnIndex: number; text: string }
65
+ | { type: 'btw_error'; topicId: string | null; message: string };