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
@@ -1,11 +1,9 @@
1
- import type { RefObject } from 'preact/compat';
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
1
+ import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
3
2
  import { useSearchParams } from '@/client/lib/router/search-params';
4
3
  import { Group, Panel, type PanelImperativeHandle } from '@/client/components/layout/desktop-layout/resizer';
5
4
  import { SessionSidebar } from '@/client/components/layout/session-sidebar/index';
6
- import { DEFAULT_RIGHT_PANEL_WIDTHS, type RightPanelType } from '@/shared/lib/workspace/right-panels';
5
+ import type { RightPanelType } from '@/shared/lib/workspace/right-panels';
7
6
  import { SettingsModal } from '@/client/components/settings/LazyModal';
8
- import { PanelLeft } from 'lucide-preact';
9
7
  import type { SettingsCategoryId } from '@/shared/types';
10
8
  import { activeProjectForSession } from '@/shared/lib/workspace/active-project';
11
9
  import { useFileTabs } from '@/client/hooks/workspace/file-tabs';
@@ -15,9 +13,8 @@ import { WorkspacePanels } from '@/client/components/layout/desktop-layout/Works
15
13
  import { useAgentStreamStatus } from '@/client/hooks/chat/omp/status';
16
14
  import { usePanelWidths } from '@/client/hooks/workspace/panel-widths';
17
15
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
18
- import { DEFAULT_PANEL_WIDTHS, MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH, type EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
16
+ import { DEFAULT_LEFT_PANEL_WIDTH, MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH, type EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
19
17
  import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
20
- import { writeSetting } from '@/shared/lib/settings/client';
21
18
  import { useChamberEvent, useWindowEvent } from '@/client/hooks/ui/window-event';
22
19
 
23
20
  interface DesktopLayoutProps {
@@ -26,29 +23,17 @@ interface DesktopLayoutProps {
26
23
  appSettings?: Record<string, any>;
27
24
  }
28
25
 
29
- /**
30
- * Push a remembered width onto a mounted panel. Panels report nothing while
31
- * unmounted, so a missing width (never resized) or a missing handle (panel
32
- * closed) simply leaves the panel at whatever its `defaultSize` derived.
33
- */
34
- function applyWidth(
35
- ref: RefObject<PanelImperativeHandle | null>,
36
- px: number | undefined,
37
- ) {
38
- if (px != null) ref.current?.resize(px);
39
- }
40
-
41
26
  export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }: DesktopLayoutProps) {
42
27
  const { folders } = useSidebarData();
43
28
  const [showRightPanel, setShowRightPanel] = useSessionState<boolean>('layout.showRightPanel', appSettings.showRightPanel ?? true);
44
29
  const [activeRightPanel, setActiveRightPanel] = useSessionState<RightPanelType>('layout.activeRightPanel', (appSettings.activeRightPanel as RightPanelType) ?? 'files');
45
- const [showLeftPanel, setShowLeftPanel] = useState(appSettings.showLeftPanel ?? true);
46
- const { widths: panelWidths, widthsRef, commitWidths } = usePanelWidths(appSettings, activeRightPanel);
30
+ const [showLeftPanel, setShowLeftPanel] = useSessionState<boolean>('layout.showLeftPanel', appSettings.showLeftPanel ?? true);
31
+ const { widths: panelWidths, commitWidths } = usePanelWidths(appSettings, activeRightPanel);
47
32
 
48
33
  const editorPanelRef = useRef<PanelImperativeHandle | null>(null);
49
34
  const rightPanelRef = useRef<PanelImperativeHandle | null>(null);
50
35
  const leftPanelRef = useRef<PanelImperativeHandle | null>(null);
51
- const [userToggledEditor, setUserToggledEditor] = useState<boolean | null>(appSettings.userToggledEditor ?? null);
36
+ const [userToggledEditor, setUserToggledEditor] = useSessionState<boolean | null>('layout.userToggledEditor', appSettings.userToggledEditor ?? null);
52
37
 
53
38
  const [searchParams] = useSearchParams();
54
39
  const folderIdParam = searchParams.get('folderId');
@@ -63,8 +48,7 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
63
48
 
64
49
  const handleOpenTab = useCallback(() => {
65
50
  setUserToggledEditor(true);
66
- saveSetting('userToggledEditor', true);
67
- }, []);
51
+ }, [setUserToggledEditor]);
68
52
 
69
53
  const {
70
54
  openedFiles,
@@ -83,42 +67,13 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
83
67
  [openedFiles, activeFileId],
84
68
  );
85
69
 
86
- const saveSetting = (key: string, value: any) => {
87
- writeSetting(key, value);
88
- };
89
-
90
- // The sidebar shares a group with the workspace stack, so the stack absorbs
91
- // its width and only the toggle can lose it: reapplying it after the panel
92
- // remounts is what keeps the sidebar where the user left it.
93
- useEffect(() => {
94
- const frame = requestAnimationFrame(() => {
95
- const widths = widthsRef.current;
96
- if (widths) applyWidth(leftPanelRef, widths.left);
97
- });
98
- return () => cancelAnimationFrame(frame);
99
- }, [showLeftPanel, widthsRef]);
100
-
101
- // The right panel survives activity-bar view switches (only CSS hides the
102
- // outgoing view), so a mounted panel never re-reads its defaultSize. A view
103
- // that has been resized gets that width pushed here; one that never has gets
104
- // the width it is supposed to open at, which the element would otherwise
105
- // have taken from the view it was mounted under.
106
- useEffect(() => {
107
- const frame = requestAnimationFrame(() => {
108
- applyWidth(rightPanelRef, widthsRef.current?.right?.[activeRightPanel] ?? DEFAULT_RIGHT_PANEL_WIDTHS[activeRightPanel]);
109
- });
110
- return () => cancelAnimationFrame(frame);
111
- }, [showRightPanel, activeRightPanel, widthsRef]);
112
-
113
- // Same story for the editor across a source↔diff tab switch: the panel
114
- // stays mounted, so the width its tab kind remembers — or opens at, for a
115
- // diff, which is wider than a source view — is re-applied here.
116
- useEffect(() => {
117
- const frame = requestAnimationFrame(() => {
118
- applyWidth(editorPanelRef, widthsRef.current?.[editorWidthMode] ?? DEFAULT_PANEL_WIDTHS[editorWidthMode]);
119
- });
120
- return () => cancelAnimationFrame(frame);
121
- }, [showEditor, editorWidthMode, widthsRef]);
70
+ // Width restoration is declarative: every panel's `defaultSize` is derived
71
+ // from the remembered width for its current slot, and `Panel` adopts a
72
+ // changed `defaultSize` itself. That covers the two cases that used to need
73
+ // an imperative push — a right-panel view switch and an editor source↔diff
74
+ // switch, both of which keep the panel mounted while only its width slot
75
+ // changes — and it cannot go stale, because there is no cached layout to
76
+ // disagree with the props.
122
77
 
123
78
  const [settingsOpen, setSettingsOpen] = useState(false);
124
79
  const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('appearance');
@@ -153,41 +108,30 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
153
108
  const handleChangeRightPanel = (panel: RightPanelType) => {
154
109
  let nextShow = showRightPanel;
155
110
  let nextActive = activeRightPanel;
156
-
111
+
157
112
  if (activeRightPanel === panel && showRightPanel) {
158
113
  nextShow = false;
159
114
  } else {
160
115
  nextActive = panel;
161
116
  nextShow = true;
162
117
  }
163
-
118
+
164
119
  setShowRightPanel(nextShow);
165
120
  setActiveRightPanel(nextActive);
166
-
167
- // The width is not set here: the restore effect re-applies whichever width
168
- // this view remembers, and a view that opens for the first time derives it
169
- // from its own defaultSize. Reopening the same view keeps its width.
170
- writeSetting('showRightPanel', nextShow);
171
- writeSetting('activeRightPanel', nextActive);
172
121
  };
173
122
 
174
123
  const handleToggleLeftPanel = (show: boolean) => {
175
124
  setShowLeftPanel(show);
176
- saveSetting('showLeftPanel', show);
177
125
  };
178
126
 
179
127
  const handleToggleRightPanel = () => {
180
- const nextShow = !showRightPanel;
181
- setShowRightPanel(nextShow);
182
- saveSetting('showRightPanel', nextShow);
128
+ setShowRightPanel(!showRightPanel);
183
129
  };
184
130
 
185
131
  const handleToggleEditor = () => {
186
- const nextShow = !showEditor;
187
- setUserToggledEditor(nextShow);
188
- saveSetting('userToggledEditor', nextShow);
189
- // Reopening the editor re-applies the width its tab kind remembers (see the
190
- // restore effect), so a dragged width survives the toggle.
132
+ setUserToggledEditor(!showEditor);
133
+ // The editor panel keeps its own remembered width per tab kind, so a
134
+ // dragged width survives the toggle without being re-applied here.
191
135
  };
192
136
 
193
137
  return (
@@ -195,18 +139,6 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
195
139
 
196
140
  {/* Main Workspace: full-height left sidebar (resizable) + right stack */}
197
141
  <div className="flex flex-1 overflow-hidden">
198
- {!showLeftPanel && (
199
- <div
200
- className="w-12 h-full bg-paper border-r border-ink/10 flex flex-col items-center py-3 flex-shrink-0 cursor-pointer hover:bg-ink/5 transition-colors titlebar-no-drag"
201
- style={{ paddingTop: 'max(0.75rem, env(titlebar-area-height, 0px))' }}
202
- onClick={() => handleToggleLeftPanel(true)}
203
- title="Expand Sidebar"
204
- >
205
- <PanelLeft size={16} className="text-ink/60" />
206
- <div className="w-[1px] flex-1 bg-ink/10 my-4" />
207
- </div>
208
- )}
209
-
210
142
  <Group
211
143
  orientation="horizontal"
212
144
  id="ompchamber-main"
@@ -215,14 +147,10 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
215
147
  if (left != null && left > 0) commitWidths({ left: Math.round(left) });
216
148
  }}
217
149
  >
218
- {showLeftPanel && (
219
- <>
220
- <Panel panelRef={leftPanelRef} id="left-panel" defaultSize={panelWidths.left ?? DEFAULT_PANEL_WIDTHS.left} minSize={MIN_LEFT_PANEL_WIDTH} maxSize={MAX_LEFT_PANEL_WIDTH} collapsible>
221
- <SessionSidebar className="w-full h-full" onClose={() => handleToggleLeftPanel(false)} appSettings={appSettings} />
222
- </Panel>
223
- <ResizeHandle />
224
- </>
225
- )}
150
+ <Panel panelRef={leftPanelRef} id="left-panel" defaultSize={panelWidths.left ?? DEFAULT_LEFT_PANEL_WIDTH} minSize={MIN_LEFT_PANEL_WIDTH} maxSize={MAX_LEFT_PANEL_WIDTH} collapsed={!showLeftPanel}>
151
+ <SessionSidebar className="w-full h-full" onClose={() => handleToggleLeftPanel(false)} appSettings={appSettings} />
152
+ </Panel>
153
+ {showLeftPanel && <ResizeHandle />}
226
154
 
227
155
  {/* Right stack: top navbar + resizable workspace (the group's filler) */}
228
156
  <Panel id="main-right-stack" filler minSize={0}>
@@ -236,6 +164,7 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
236
164
  onSwitchToMobile={onSwitchToMobile}
237
165
  onToggleEditor={handleToggleEditor}
238
166
  onToggleRightPanel={handleToggleRightPanel}
167
+ onToggleLeftPanel={() => handleToggleLeftPanel(!showLeftPanel)}
239
168
  />
240
169
 
241
170
  <WorkspacePanels
@@ -1,8 +1,9 @@
1
1
  import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'preact/compat';
2
2
  import { useRef as useRefBase } from 'preact/hooks';
3
3
  import type { ComponentChildren, RefObject } from 'preact';
4
- import type { CSSProperties, TargetedPointerEvent } from 'preact';
5
- import { clamp, toPx, transfer } from '@/client/components/layout/desktop-layout/resizer/utils';
4
+ import type { CSSProperties } from 'preact';
5
+ import { clamp, toPx } from '@/client/components/layout/desktop-layout/resizer/utils';
6
+ import { useSeparatorDrag } from '@/client/components/layout/desktop-layout/resizer/separator-drag';
6
7
 
7
8
  /**
8
9
  * Hand-rolled resizable panel group (the Preact-native replacement for
@@ -15,6 +16,10 @@ import { clamp, toPx, transfer } from '@/client/components/layout/desktop-layout
15
16
  * clamped by both neighbors' min/max constraints, and commits once per drag
16
17
  * through `onLayoutChanged(_, { isUserInteraction: true })`.
17
18
  *
19
+ * Sizes are pixels here. A caller that stores a width as a share of the group's
20
+ * area (see `panel-widths.ts`) resolves it to px before passing it in — the
21
+ * resizer has no percentage path and should not grow one.
22
+ *
18
23
  * The filler's `minSize` is a real CSS floor, which is what makes a shrinking
19
24
  * window push the resizable panels toward their own floors instead of
20
25
  * collapsing the filler to nothing (a zero flex basis absorbs no negative free
@@ -39,14 +44,9 @@ export interface PanelImperativeHandle {
39
44
  getSize: () => { inPixels: number } | null;
40
45
  }
41
46
 
42
- export interface GroupImperativeHandle {
43
- /** Batch resize of the named panels, in pixels. */
44
- setLayout: (layout: Record<string, number>) => void;
45
- }
46
-
47
47
  export type LayoutChangedHandler = (layout: Record<string, number>, meta: LayoutMeta) => void;
48
48
 
49
- interface PanelState {
49
+ export interface PanelState {
50
50
  id: string;
51
51
  isFiller: boolean;
52
52
  minSize: number;
@@ -69,7 +69,8 @@ const GroupContext = createContext<GroupContextValue | null>(null);
69
69
  export function Group(props: {
70
70
  orientation: GroupOrientation;
71
71
  id?: string;
72
- groupRef?: RefObject<GroupImperativeHandle | null>;
72
+ /** The group's own element, for measuring the area its panels share. */
73
+ groupRef?: RefObject<HTMLDivElement>;
73
74
  onLayoutChanged?: LayoutChangedHandler;
74
75
  className?: string;
75
76
  children: ComponentChildren;
@@ -86,35 +87,10 @@ export function Group(props: {
86
87
  [orientation],
87
88
  );
88
89
 
89
- const snapshot = () => {
90
- const layout: Record<string, number> = {};
91
- for (const [pid, state] of panelsRef.current) {
92
- const px = state.getSize();
93
- if (px != null) layout[pid] = px;
94
- }
95
- return layout;
96
- };
97
-
98
- useEffect(() => {
99
- if (!groupRef) return;
100
- groupRef.current = {
101
- setLayout: (layout) => {
102
- for (const [pid, px] of Object.entries(layout)) {
103
- const state = panelsRef.current.get(pid);
104
- if (!state || state.isFiller || typeof px !== 'number') continue;
105
- state.setSize(clamp(px, state.minSize, state.maxSize));
106
- }
107
- changedRef.current?.(snapshot(), { isUserInteraction: false });
108
- },
109
- };
110
- return () => {
111
- groupRef.current = null;
112
- };
113
- }, [groupRef]);
114
-
115
90
  return (
116
91
  <GroupContext.Provider value={ctx}>
117
92
  <div
93
+ ref={groupRef}
118
94
  data-panel-group=""
119
95
  data-panel-group-id={id}
120
96
  className={className}
@@ -133,14 +109,19 @@ export function Panel(props: {
133
109
  defaultSize?: number | string;
134
110
  minSize?: number | string;
135
111
  maxSize?: number | string;
136
- collapsible?: boolean;
112
+ /**
113
+ * Render at zero width without unmounting. The panel keeps its size state, so
114
+ * reopening restores the remembered width and the width transition animates
115
+ * the movement instead of the content popping in.
116
+ */
117
+ collapsed?: boolean;
137
118
  /** This panel absorbs the remaining space instead of holding its own px. */
138
119
  filler?: boolean;
139
120
  className?: string;
140
121
  style?: CSSProperties;
141
122
  children: ComponentChildren;
142
123
  }) {
143
- const { id, panelRef, defaultSize, minSize = 0, maxSize, filler = false, className, style, children } = props;
124
+ const { id, panelRef, defaultSize, minSize = 0, maxSize, collapsed = false, filler = false, className, style, children } = props;
144
125
  const group = useContext(GroupContext);
145
126
  if (!group) {
146
127
  throw new Error(`<Panel id="${id}"> must be rendered inside a <Group>`);
@@ -184,6 +165,21 @@ export function Panel(props: {
184
165
  }
185
166
  });
186
167
 
168
+ // Adopt a changed `defaultSize`. This is what restores a panel after its
169
+ // owner swaps it — a right-panel view switch, an editor source↔diff switch —
170
+ // and after a resize recomputes it, without an imperative push from the
171
+ // owner. A drag does not fight it: the owner only produces a new
172
+ // `defaultSize` once the drag commits, and that value is the size the drag
173
+ // already applied.
174
+ const lastDefaultPxRef = useRef<number | null>(null);
175
+ useEffect(() => {
176
+ if (filler || defaultSize == null) return;
177
+ const nextPx = toPx(defaultSize, minPx);
178
+ if (lastDefaultPxRef.current === nextPx) return;
179
+ lastDefaultPxRef.current = nextPx;
180
+ setSizePx(clamp(nextPx, minPx, maxPx));
181
+ }, [defaultSize, filler, minPx, maxPx]);
182
+
187
183
  useEffect(() => {
188
184
  if (!panelRef) return;
189
185
  panelRef.current = {
@@ -212,17 +208,29 @@ export function Panel(props: {
212
208
  }
213
209
  : {
214
210
  flex: '0 1 auto',
215
- width: orientation === 'horizontal' ? sizePx ?? undefined : undefined,
216
- height: orientation === 'horizontal' ? undefined : sizePx ?? undefined,
217
- minWidth: orientation === 'horizontal' ? minPx : undefined,
218
- minHeight: orientation === 'horizontal' ? undefined : minPx,
211
+ width: orientation === 'horizontal' ? (collapsed ? 0 : sizePx ?? undefined) : undefined,
212
+ height: orientation === 'horizontal' ? undefined : collapsed ? 0 : sizePx ?? undefined,
213
+ minWidth: orientation === 'horizontal' ? (collapsed ? 0 : minPx) : undefined,
214
+ minHeight: orientation === 'horizontal' ? undefined : collapsed ? 0 : minPx,
219
215
  maxWidth: maxSize != null ? maxPx : undefined,
216
+ // A drag re-applies the real width at most every RESIZE_FOLLOW_INTERVAL_MS
217
+ // (see useSeparatorDrag), and this transition is what makes those steps
218
+ // read as one movement. It also carries a panel to its remembered width
219
+ // on a view switch, and to zero on collapse, instead of snapping.
220
+ transition: `${orientation === 'horizontal' ? 'width' : 'height'} 200ms cubic-bezier(0.22, 1, 0.36, 1)`,
220
221
  overflow: 'hidden',
221
222
  position: 'relative',
222
223
  };
223
224
 
224
225
  return (
225
- <div ref={elementRef} data-panel="" data-panel-id={id} className={className} style={{ ...baseStyle, ...style }}>
226
+ <div
227
+ ref={elementRef}
228
+ data-panel=""
229
+ data-panel-id={id}
230
+ data-panel-collapsed={collapsed ? '' : undefined}
231
+ className={className}
232
+ style={{ ...baseStyle, ...style }}
233
+ >
226
234
  {children}
227
235
  </div>
228
236
  );
@@ -240,88 +248,26 @@ export function Panel(props: {
240
248
  export function Separator(props: { className?: string; style?: CSSProperties; children?: ComponentChildren }) {
241
249
  const { className, style, children } = props;
242
250
  const group = useContext(GroupContext);
243
- const [dragging, setDragging] = useState(false);
244
251
  const hostRef = useRef<HTMLDivElement | null>(null);
245
- const dragCleanupRef = useRef<(() => void) | null>(null);
246
-
247
- // A drag owns two window listeners; if the separator unmounts mid-drag (a
248
- // layout switch) nothing else would ever remove them.
249
- useEffect(() => () => dragCleanupRef.current?.(), []);
250
252
 
251
253
  if (!group) {
252
254
  throw new Error('<Separator> must be rendered inside a <Group>');
253
255
  }
254
256
  const { orientation, panels, onLayoutChanged } = group;
255
-
256
- const handlePointerDown = (e: TargetedPointerEvent<HTMLDivElement>) => {
257
- const host = hostRef.current;
258
- if (!host || e.button !== 0) return;
259
- const previous = host.previousElementSibling as HTMLElement | null;
260
- const next = host.nextElementSibling as HTMLElement | null;
261
- const aId = previous?.getAttribute('data-panel-id');
262
- const bId = next?.getAttribute('data-panel-id');
263
- const a = aId ? panels.current.get(aId) : undefined;
264
- const b = bId ? panels.current.get(bId) : undefined;
265
- if (!a || !b || !previous || !next) return;
266
-
267
- e.preventDefault();
268
- setDragging(true);
269
-
270
- const startPos = orientation === 'horizontal' ? e.clientX : e.clientY;
271
- const measure = (el: HTMLElement) =>
272
- orientation === 'horizontal' ? el.getBoundingClientRect().width : el.getBoundingClientRect().height;
273
- // Measure, never ask the panel: a window narrow enough for flexbox to
274
- // shrink a panel below its specified width would otherwise start the drag
275
- // from a size the panel does not have on screen.
276
- const aStart = measure(previous);
277
- const bStart = measure(next);
278
-
279
- const onMove = (ev: PointerEvent) => {
280
- const pos = orientation === 'horizontal' ? ev.clientX : ev.clientY;
281
- const delta = transfer(
282
- { size: aStart, min: a.minSize, max: a.maxSize },
283
- { size: bStart, min: b.minSize, max: b.maxSize },
284
- pos - startPos,
285
- );
286
- a.setSize(clamp(aStart + delta, a.minSize, a.maxSize));
287
- if (!b.isFiller) b.setSize(clamp(bStart - delta, b.minSize, b.maxSize));
288
- };
289
-
290
- const cleanup = () => {
291
- window.removeEventListener('pointermove', onMove);
292
- window.removeEventListener('pointerup', onUp);
293
- window.removeEventListener('pointercancel', onUp);
294
- dragCleanupRef.current = null;
295
- setDragging(false);
296
- const layout: Record<string, number> = {};
297
- for (const [pid, state] of panels.current) {
298
- const px = state.getSize();
299
- if (px != null) layout[pid] = px;
300
- }
301
- onLayoutChanged.current?.(layout, { isUserInteraction: true });
302
- };
303
- const onUp = () => cleanup();
304
-
305
- window.addEventListener('pointermove', onMove);
306
- window.addEventListener('pointerup', onUp);
307
- window.addEventListener('pointercancel', onUp);
308
- dragCleanupRef.current = cleanup;
309
- };
257
+ const { dragging, guidePx, onPointerDown } = useSeparatorDrag(hostRef, orientation, panels, onLayoutChanged);
310
258
 
311
259
  return (
312
- <>
313
- <div
314
- ref={hostRef}
315
- data-separator=""
316
- role="separator"
317
- aria-orientation={orientation === 'horizontal' ? 'vertical' : 'horizontal'}
318
- className={className}
319
- style={style}
320
- onPointerDown={handlePointerDown}
321
- onDragStart={(e: DragEvent) => e.preventDefault()}
322
- >
323
- {children}
324
- </div>
260
+ <div
261
+ ref={hostRef}
262
+ data-separator=""
263
+ role="separator"
264
+ aria-orientation={orientation === 'horizontal' ? 'vertical' : 'horizontal'}
265
+ className={className}
266
+ style={style}
267
+ onPointerDown={onPointerDown}
268
+ onDragStart={(e: DragEvent) => e.preventDefault()}
269
+ >
270
+ {children}
325
271
  {dragging && (
326
272
  <div
327
273
  style={{
@@ -332,6 +278,21 @@ export function Separator(props: { className?: string; style?: CSSProperties; ch
332
278
  } as CSSProperties}
333
279
  />
334
280
  )}
335
- </>
281
+ {guidePx !== null && (
282
+ <div
283
+ aria-hidden="true"
284
+ style={{
285
+ position: 'fixed',
286
+ ...(orientation === 'horizontal'
287
+ ? { left: `${guidePx}px`, top: 0, bottom: 0, width: '1px' }
288
+ : { top: `${guidePx}px`, left: 0, right: 0, height: '1px' }),
289
+ zIndex: 9998,
290
+ pointerEvents: 'none',
291
+ background: 'var(--theme-ink)',
292
+ opacity: 0.35,
293
+ } as CSSProperties}
294
+ />
295
+ )}
296
+ </div>
336
297
  );
337
- }
298
+ }
@@ -0,0 +1,140 @@
1
+ import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import type { RefObject } from 'preact';
3
+ import type { TargetedPointerEvent } from 'preact';
4
+ import { clamp, transfer } from '@/client/components/layout/desktop-layout/resizer/utils';
5
+ import { RESIZE_FOLLOW_INTERVAL_MS } from '@/shared/lib/workspace/panel-widths';
6
+ import type { GroupOrientation, LayoutChangedHandler, PanelState } from '@/client/components/layout/desktop-layout/resizer';
7
+
8
+ export interface SeparatorDrag {
9
+ /** A drag is in flight; the caller renders the pointer-capture blocker. */
10
+ dragging: boolean;
11
+ /** Pointer position of the guide line, or null when not dragging. */
12
+ guidePx: number | null;
13
+ onPointerDown: (event: TargetedPointerEvent<HTMLDivElement>) => void;
14
+ }
15
+
16
+ /**
17
+ * The deferred drag behind a separator.
18
+ *
19
+ * A ghost guide line follows the pointer, and the real width is re-applied at
20
+ * most every `RESIZE_FOLLOW_INTERVAL_MS`. Applying it on every pointermove
21
+ * re-renders both panels per frame and reflows whatever they contain — an xterm
22
+ * buffer, a CodeMirror document, a browser iframe — for a width the user is
23
+ * still choosing. The panel's own width transition smooths the steps that land.
24
+ *
25
+ * Kept out of `index.tsx` so the components stay inside the repo's file-size
26
+ * ceiling.
27
+ */
28
+ export function useSeparatorDrag(
29
+ hostRef: RefObject<HTMLDivElement | null>,
30
+ orientation: GroupOrientation,
31
+ panels: { current: Map<string, PanelState> },
32
+ onLayoutChanged: { current: LayoutChangedHandler | null },
33
+ ): SeparatorDrag {
34
+ const [dragging, setDragging] = useState(false);
35
+ // State, not a ref: the guide line has to move with the pointer even though
36
+ // the panels deliberately do not.
37
+ const [guidePx, setGuidePx] = useState<number | null>(null);
38
+ const cleanupRef = useRef<(() => void) | null>(null);
39
+
40
+ // A drag owns two window listeners; if the separator unmounts mid-drag (a
41
+ // layout switch) nothing else would ever remove them.
42
+ useEffect(() => () => cleanupRef.current?.(), []);
43
+
44
+ const onPointerDown = (e: TargetedPointerEvent<HTMLDivElement>) => {
45
+ const host = hostRef.current;
46
+ if (!host || e.button !== 0) return;
47
+ // Walk past separators and collapsed panels to the real neighbours. A
48
+ // collapsed panel keeps its DOM node (so its state survives and the width
49
+ // transition has something to animate), but it is zero-width and its own
50
+ // floor still reads as 320 in the registry — pairing a drag against it
51
+ // would clamp every delta to zero and kill the separator.
52
+ const neighbour = (start: Element | null, step: 'previous' | 'next') => {
53
+ let node = start;
54
+ while (node) {
55
+ if (node.hasAttribute('data-separator') || node.hasAttribute('data-panel-collapsed')) {
56
+ node = step === 'previous' ? node.previousElementSibling : node.nextElementSibling;
57
+ continue;
58
+ }
59
+ return node instanceof HTMLElement && node.hasAttribute('data-panel') ? node : null;
60
+ }
61
+ return null;
62
+ };
63
+
64
+ const previous = neighbour(host.previousElementSibling, 'previous');
65
+ const next = neighbour(host.nextElementSibling, 'next');
66
+ const a = previous ? panels.current.get(previous.getAttribute('data-panel-id') ?? '') : undefined;
67
+ const b = next ? panels.current.get(next.getAttribute('data-panel-id') ?? '') : undefined;
68
+ if (!a || !b || !previous || !next) return;
69
+
70
+ e.preventDefault();
71
+ setDragging(true);
72
+
73
+ const horizontal = orientation === 'horizontal';
74
+ const startPos = horizontal ? e.clientX : e.clientY;
75
+ const measure = (el: HTMLElement) =>
76
+ horizontal ? el.getBoundingClientRect().width : el.getBoundingClientRect().height;
77
+ // Measure, never ask the panel: a window narrow enough for flexbox to
78
+ // shrink a panel below its specified width would otherwise start the drag
79
+ // from a size the panel does not have on screen.
80
+ const aStart = measure(previous);
81
+ const bStart = measure(next);
82
+
83
+ // The size the throttle last committed, kept apart from the guide line so
84
+ // every step that lands is a legal clamped size.
85
+ let pending: { a: number; b: number } | null = null;
86
+ let followTimer: ReturnType<typeof setTimeout> | undefined;
87
+
88
+ const apply = (sizes: { a: number; b: number }) => {
89
+ a.setSize(sizes.a);
90
+ if (!b.isFiller) b.setSize(sizes.b);
91
+ };
92
+
93
+ const onMove = (ev: PointerEvent) => {
94
+ const pos = horizontal ? ev.clientX : ev.clientY;
95
+ const delta = transfer(
96
+ { size: aStart, min: a.minSize, max: a.maxSize },
97
+ { size: bStart, min: b.minSize, max: b.maxSize },
98
+ pos - startPos,
99
+ );
100
+ pending = {
101
+ a: clamp(aStart + delta, a.minSize, a.maxSize),
102
+ b: clamp(bStart - delta, b.minSize, b.maxSize),
103
+ };
104
+ setGuidePx(pos);
105
+ if (followTimer === undefined) {
106
+ followTimer = setTimeout(() => {
107
+ followTimer = undefined;
108
+ if (pending) apply(pending);
109
+ }, RESIZE_FOLLOW_INTERVAL_MS);
110
+ }
111
+ };
112
+
113
+ const onUp = () => {
114
+ window.removeEventListener('pointermove', onMove);
115
+ window.removeEventListener('pointerup', onUp);
116
+ window.removeEventListener('pointercancel', onUp);
117
+ clearTimeout(followTimer);
118
+ cleanupRef.current = null;
119
+ setDragging(false);
120
+ setGuidePx(null);
121
+ // Land the exact final size once, so the release position is the one the
122
+ // guide line showed rather than the last throttled step.
123
+ if (pending) apply(pending);
124
+
125
+ const layout: Record<string, number> = {};
126
+ for (const [pid, state] of panels.current) {
127
+ const px = state.getSize();
128
+ if (px != null) layout[pid] = px;
129
+ }
130
+ onLayoutChanged.current?.(layout, { isUserInteraction: true });
131
+ };
132
+
133
+ window.addEventListener('pointermove', onMove);
134
+ window.addEventListener('pointerup', onUp);
135
+ window.addEventListener('pointercancel', onUp);
136
+ cleanupRef.current = onUp;
137
+ };
138
+
139
+ return { dragging, guidePx, onPointerDown };
140
+ }
@@ -5,7 +5,7 @@ export function SessionSidebarHeader({ onNewSession }: { onNewSession: () => voi
5
5
  <>
6
6
  {/* App Title */}
7
7
  <div
8
- className="h-12 flex-shrink-0 flex items-center px-4 border-b border-ink/10 titlebar-drag-region select-none"
8
+ className="h-14 flex-shrink-0 flex items-center px-4 border-b border-ink/10 titlebar-drag-region select-none"
9
9
  style={{ paddingLeft: 'max(1rem, env(titlebar-area-x, 0px))' }}
10
10
  >
11
11
  <span className="font-bold text-sm tracking-tight flex items-center">