@kabmo-labs/kabmo 0.8.0 → 0.9.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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,189 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One toolbar row that never becomes two.
8
+ *
9
+ * The editor and diff toolbars carry ten-plus controls and the editor panel is
10
+ * 320px wide on a 1440px screen. WRAPPING was the previous answer and it is the
11
+ * wrong one: a second row moves the document down by the toolbar's own height
12
+ * and reads as a second toolbar. A horizontal scroll is worse — a clipped
13
+ * control is invisible. So the clusters that do not fit move into a `⋯` menu and
14
+ * every control stays one click away.
15
+ *
16
+ * Widths are MEASURED, never tabulated: the row lays every cluster out once and
17
+ * reads each one's rendered width, then hides what does not fit. A table of
18
+ * guessed pixel widths drifts the moment a label, an icon or a font changes, and
19
+ * a wrong guess here is a control that is either clipped or needlessly buried.
20
+ */
21
+
22
+ import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
23
+ import type { ComponentChildren } from 'preact';
24
+ import { Ellipsis, type LucideIcon } from 'lucide-preact';
25
+ import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
26
+ import { toolbarHasOverflow, toolbarVisibleClusters } from '@/shared/lib/ui/toolbar-overflow';
27
+
28
+ /** One row the `⋯` menu offers when its cluster is not on the row. */
29
+ export interface ToolbarOverflowAction {
30
+ label: string;
31
+ icon: LucideIcon;
32
+ onSelect: () => void;
33
+ /** Rendered as a pressed row (word wrap on, a mode already active). */
34
+ active?: boolean;
35
+ disabled?: boolean;
36
+ }
37
+
38
+ /**
39
+ * A logical group of controls that hides as a unit.
40
+ *
41
+ * A group rather than a single button because the clusters are already grouped
42
+ * in the toolbars (zoom is two buttons, copy/download is a pair, the width modes
43
+ * are a pair) and splitting them would let half of a pair stay on the row.
44
+ */
45
+ export interface ToolbarCluster {
46
+ id: string;
47
+ /**
48
+ * Never leave the row, whatever its width.
49
+ *
50
+ * The visual order is not the priority order: Save is drawn first because that
51
+ * is where a reader looks for it, while the width modes sit last and are the
52
+ * reason the panel exists. Pinning says which ones a menu row must not be the
53
+ * only way to reach.
54
+ */
55
+ pinned?: boolean;
56
+ /** What the `⋯` menu offers when this cluster is hidden. */
57
+ actions: ToolbarOverflowAction[];
58
+ children: ComponentChildren;
59
+ }
60
+
61
+ interface ToolbarRowProps {
62
+ clusters: ToolbarCluster[];
63
+ className?: string;
64
+ }
65
+
66
+ export function ToolbarRow({ clusters, className = '' }: ToolbarRowProps) {
67
+ const rowRef = useRef<HTMLDivElement>(null);
68
+ /** Measured widths, tagged with the cluster set they describe. */
69
+ const [metrics, setMetrics] = useState<{ signature: string; widths: number[] } | null>(null);
70
+ const [available, setAvailable] = useState(0);
71
+
72
+ // The set changes when a conditional cluster appears or disappears (a
73
+ // previewable file opened, a byte viewer took over), and stale widths would
74
+ // then hide the wrong cluster. Rendering every cluster until they are measured
75
+ // is what gives the ruler something to measure — and the layout effect runs
76
+ // BEFORE paint, so the unresolved frame is never seen.
77
+ const signature = clusters.map((cluster) => `${cluster.id}:${cluster.pinned ? 1 : 0}`).join('|');
78
+ const ready = metrics?.signature === signature;
79
+ const widths = ready ? metrics.widths : [];
80
+ const pinned = clusters.map((cluster) => cluster.pinned === true);
81
+ const visible = widths.length > 0
82
+ ? toolbarVisibleClusters({ widths, pinned, available })
83
+ : clusters.map(() => true);
84
+
85
+ useLayoutEffect(() => {
86
+ const row = rowRef.current;
87
+ if (!row) return;
88
+ if (!ready) {
89
+ const children = [...row.querySelectorAll<HTMLElement>('[data-toolbar-cluster]')];
90
+ if (children.length === clusters.length) {
91
+ setMetrics({ signature, widths: children.map((child) => child.getBoundingClientRect().width) });
92
+ }
93
+ }
94
+ setAvailable(row.clientWidth);
95
+ });
96
+
97
+ // The row's width changes on a window resize, a panel drag and a layout mode
98
+ // switch — none of which are `window` events.
99
+ useEffect(() => {
100
+ const row = rowRef.current;
101
+ if (!row || typeof ResizeObserver === 'undefined') return;
102
+ const observer = new ResizeObserver(() => setAvailable(row.clientWidth));
103
+ observer.observe(row);
104
+ return () => observer.disconnect();
105
+ }, []);
106
+
107
+ const hiddenActions = clusters.filter((_, index) => !visible[index]).flatMap((cluster) => cluster.actions);
108
+
109
+ return (
110
+ <div ref={rowRef} className={`flex flex-nowrap items-center justify-end gap-x-1 overflow-hidden ${className}`}>
111
+ {clusters.map((cluster, index) =>
112
+ visible[index] ? (
113
+ <div key={cluster.id} data-toolbar-cluster="" className="flex items-center flex-shrink-0">
114
+ {cluster.children}
115
+ </div>
116
+ ) : null,
117
+ )}
118
+ {toolbarHasOverflow(visible) && <OverflowMenu actions={hiddenActions} />}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ /**
124
+ * The `⋯` trigger and its menu.
125
+ *
126
+ * A menu rather than a horizontal scroll: a clipped action is invisible, and the
127
+ * diff toolbar already learned that a scroll strip with no visible scrollbar
128
+ * reads as "there is nothing more here" (measured: 361px of content in a 296px
129
+ * box, with `Stage` entirely outside it).
130
+ */
131
+ function OverflowMenu({ actions }: { actions: ToolbarOverflowAction[] }) {
132
+ const [open, setOpen] = useState(false);
133
+ const rootRef = useRef<HTMLDivElement>(null);
134
+ useOnClickOutside(rootRef, () => setOpen(false));
135
+
136
+ useEffect(() => {
137
+ if (!open) return;
138
+ const onKeyDown = (event: globalThis.KeyboardEvent) => {
139
+ if (event.key === 'Escape') setOpen(false);
140
+ };
141
+ document.addEventListener('keydown', onKeyDown);
142
+ return () => document.removeEventListener('keydown', onKeyDown);
143
+ }, [open]);
144
+
145
+ return (
146
+ <div className="relative flex-shrink-0" ref={rootRef}>
147
+ <button
148
+ type="button"
149
+ title="More actions"
150
+ aria-label="More actions"
151
+ aria-haspopup="menu"
152
+ aria-expanded={open}
153
+ onClick={() => setOpen(!open)}
154
+ className={`flex items-center justify-center p-1.5 rounded transition-colors cursor-pointer ${
155
+ open ? 'bg-ink/10 text-ink' : 'text-ink/50 hover:text-ink hover:bg-ink/5'
156
+ }`}
157
+ >
158
+ <Ellipsis size={14} />
159
+ </button>
160
+
161
+ {open && (
162
+ <div
163
+ role="menu"
164
+ className="absolute right-0 top-full mt-1 w-52 bg-paper border border-ink/15 rounded-lg shadow-lg z-50 py-1 text-xs"
165
+ >
166
+ {actions.map((action) => (
167
+ <button
168
+ key={action.label}
169
+ type="button"
170
+ role="menuitem"
171
+ disabled={action.disabled}
172
+ aria-pressed={action.active}
173
+ onClick={() => {
174
+ setOpen(false);
175
+ action.onSelect();
176
+ }}
177
+ className={`w-full text-left flex items-center gap-2 px-3 py-1.5 transition-colors disabled:cursor-default disabled:opacity-40 ${
178
+ action.active ? 'bg-ink/5 text-ink font-medium' : 'text-ink/75 hover:bg-ink/5 hover:text-ink'
179
+ }`}
180
+ >
181
+ <action.icon size={13} className="flex-shrink-0" />
182
+ <span className="truncate">{action.label}</span>
183
+ </button>
184
+ ))}
185
+ </div>
186
+ )}
187
+ </div>
188
+ );
189
+ }
@@ -13,18 +13,13 @@ import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHa
13
13
  import { useAvailableWidth } from '@/client/hooks/workspace/available-width';
14
14
  import { useSessionStateContext } from '@/client/hooks/workspace/session-state/context';
15
15
  import {
16
- DEFAULT_EDITOR_FRACTIONS,
17
- DEFAULT_EDITOR_WIDTHS,
18
16
  HANDLE_WIDTH,
19
17
  MIN_EDITOR_PANEL_WIDTH,
20
18
  fillerMinWidth,
21
- pairMaxSize,
22
- panelFraction,
23
- resolvePanelWidth,
24
19
  type EditorWidthMode,
25
20
  type PanelWidths,
26
21
  } from '@/shared/lib/workspace/panel-widths';
27
- import { DEFAULT_RIGHT_PANEL_FRACTION } from '@/shared/lib/workspace/right-panels';
22
+ import { resolveWorkspacePanelWidths } from '@/shared/lib/workspace/workspace-panel-widths';
28
23
 
29
24
  const Editor = lazy(() => import('@/client/components/workspace/editor/index').then((m) => ({ default: m.Editor })));
30
25
 
@@ -43,6 +38,13 @@ function PanelSuspense({ children }: { children: ReactNode }) {
43
38
  interface WorkspacePanelsProps {
44
39
  appSettings?: Record<string, any>;
45
40
  showEditor: boolean;
41
+ /**
42
+ * The chat column is collapsed: the editor's compact/full view took its
43
+ * width. The chat STAYS MOUNTED (it owns the live stream and the composer)
44
+ * but is sized to nothing and marked inert, so a run in progress is not
45
+ * thrown away by widening a document.
46
+ */
47
+ chatHidden?: boolean;
46
48
  editorPanelRef: RefObject<PanelImperativeHandle | null>;
47
49
  showRightPanel: boolean;
48
50
  /** A built-in view id or a plugin panel key (`plugin:<id>/<panel>`). */
@@ -78,6 +80,7 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
78
80
  const {
79
81
  appSettings,
80
82
  showEditor,
83
+ chatHidden = false,
81
84
  editorPanelRef,
82
85
  showRightPanel,
83
86
  activeRightPanel,
@@ -138,75 +141,27 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
138
141
  // one, which is why a Kabmo without one looks exactly as it did.
139
142
  const editorPlugins = usePanelSlotPlugins();
140
143
 
141
- // Separators present in this group, which also consume width.
142
- const handleCount = (showEditor ? 1 : 0) + (showRightPanel ? 1 : 0);
143
-
144
- // The active view's own floor, resolved once here rather than at each use, so
145
- // the right panel's floor cannot be computed from different values. A view
146
- // that is switched off (or a plugin that has not loaded) has no entry, and the
147
- // right panel falls back to the editor's own floor — it renders the notice,
148
- // not a view.
149
- const rightPanelMin = activePanel?.minWidth ?? MIN_EDITOR_PANEL_WIDTH;
150
-
151
- // The editor and the right panel are a COUPLED pair (see `panel-widths.ts`):
152
- // they share `PAIR_MAX_TOTAL_FRACTION` and each is capped by `PAIR_MAX_FRACTION`.
153
- // The right panel is resolved first, so it reserves the editor's WANTED share
154
- // (the editor has no result yet); the editor then reserves the right panel's
155
- // ACTUAL width. That asymmetry is deliberate: an editor asking for more than
156
- // its default makes the right panel give way first, and the pair still sums
157
- // to the total.
158
- const editorFraction = panelFraction(panelWidths[editorWidthMode], DEFAULT_EDITOR_FRACTIONS[editorWidthMode], available);
159
- const editorReservePx = available != null && available > 0 ? Math.round(editorFraction * available) : 0;
160
-
161
- // Resolve the RIGHT panel first, then the editor against the width it
162
- // actually got. The px order is the other half of the contract: each panel's
163
- // group budget is `available − chat floor − the sibling's reserved width −
164
- // handles`, so reserving only a sibling's FLOOR (the previous shape) let the
165
- // two declared widths sum past the group, and flexbox then shrank BOTH —
166
- // measured at 1440 with both open: the editor asked for 507 and rendered 477,
167
- // the right panel asked for 245 and rendered 230. The right panel is resolved
168
- // first because it is the smaller, view-specific slot: it keeps its own width
169
- // and the editor absorbs the remainder.
170
- const rightWidth = resolvePanelWidth({
171
- stored: panelWidths.right?.[activeRightPanel],
172
- defaultFraction: activePanel?.defaultFraction ?? DEFAULT_RIGHT_PANEL_FRACTION,
173
- defaultPx: activePanel?.defaultWidth ?? MIN_EDITOR_PANEL_WIDTH,
174
- available,
175
- min: rightPanelMin,
176
- // The pair cap: the right panel may not exceed `total − the editor's wanted share`.
177
- pairReservePx: showEditor ? editorReservePx : 0,
178
- // The editor renders before the right panel and has not been resolved yet,
179
- // so only its floor can be reserved here.
180
- siblingWidth: showEditor ? MIN_EDITOR_PANEL_WIDTH : 0,
181
- handleCount,
182
- });
183
-
184
- const editorWidth = resolvePanelWidth({
185
- stored: panelWidths[editorWidthMode],
186
- defaultFraction: DEFAULT_EDITOR_FRACTIONS[editorWidthMode],
187
- defaultPx: DEFAULT_EDITOR_WIDTHS[editorWidthMode],
188
- available,
189
- min: MIN_EDITOR_PANEL_WIDTH,
190
- // The pair cap: the editor may not exceed `total − the right panel's width`.
191
- pairReservePx: showRightPanel ? rightWidth : 0,
192
- // The right panel is resolved above; reserve the width it actually took so
193
- // the editor's group budget cannot push the pair past the group.
194
- siblingWidth: showRightPanel ? rightWidth : 0,
144
+ // The active view's own floor and defaults are read here and handed to the
145
+ // resolver: a view that is switched off (or a plugin that has not loaded) has
146
+ // no entry, and the right panel falls back to the editor's own floor — it
147
+ // renders the notice, not a view.
148
+ const {
149
+ editor: editorWidth,
150
+ right: rightWidth,
151
+ editorMax: editorMaxSize,
152
+ rightMax: rightMaxSize,
153
+ rightMin: rightPanelMin,
195
154
  handleCount,
196
- });
197
-
198
- /**
199
- * Pair-aware maxima for a DRAG. `resolvePanelWidth` already caps the widths
200
- * it hands to `defaultSize`, but a separator drag bypasses it — the resizer
201
- * transfers pixels between two panels clamped only by their own `maxSize`.
202
- * Without these, dragging the editor wide would push the pair past
203
- * `PAIR_MAX_TOTAL_FRACTION`. Each panel's max is its own cap and the pair
204
- * total minus the sibling's FLOOR (the sibling gives way down to its minimum
205
- * during the drag), so the pair cannot exceed 0.7 by dragging either way.
206
- * `undefined` before the group is measured (no cap yet).
207
- */
208
- const editorMaxSize = pairMaxSize({ available, siblingMin: rightPanelMin, siblingOpen: showRightPanel });
209
- const rightMaxSize = pairMaxSize({ available, siblingMin: MIN_EDITOR_PANEL_WIDTH, siblingOpen: showEditor });
155
+ } = resolveWorkspacePanelWidths({
156
+ available,
157
+ panelWidths,
158
+ editorWidthMode,
159
+ activeRightPanel,
160
+ activePanel,
161
+ showEditor,
162
+ showRightPanel,
163
+ chatHidden,
164
+ });
210
165
 
211
166
  /**
212
167
  * Widths of the fixed panels as they are right now, keyed by slot: the
@@ -222,7 +177,11 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
222
177
  const patch: PanelWidths = {};
223
178
  const editor = editorPanelRef.current?.getSize()?.inPixels;
224
179
  const right = rightPanelRef.current?.getSize()?.inPixels;
225
- if (editor != null && editor > 0) {
180
+ // With the chat hidden the editor's width is whatever is left over, not a
181
+ // choice — persisting it would overwrite the width the user set for the
182
+ // ordinary layout with the one this mode produced. A drag made here still
183
+ // moves the RIGHT panel, which is the width that was actually chosen.
184
+ if (!chatHidden && editor != null && editor > 0) {
226
185
  patch[editorWidthMode] = { px: Math.round(editor), ...(available ? { fraction: editor / available } : {}) };
227
186
  }
228
187
  if (right != null && right > 0) {
@@ -250,6 +209,10 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
250
209
  <Panel
251
210
  id="center-panel"
252
211
  filler
212
+ // Collapsed — not unmounted — in compact/full view: the chat holds the
213
+ // live run, the composer draft and the scroll position, and widening a
214
+ // document must not discard any of them.
215
+ collapsed={chatHidden}
253
216
  minSize={fillerMinWidth({
254
217
  available,
255
218
  reservedPx:
@@ -258,15 +221,15 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
258
221
  handleCount * HANDLE_WIDTH,
259
222
  })}
260
223
  >
261
- <div className="relative w-full h-full" {...drop.dropProps}>
224
+ <div className="relative w-full h-full" {...drop.dropProps} {...drop.captureDropProps} inert={chatHidden}>
262
225
  <EditorDropOverlay visible={drop.isDragging} />
263
226
  <ChatTimeline className="w-full h-full" appSettings={appSettings} onSessionTitle={onSessionTitle} />
264
227
  </div>
265
228
  </Panel>
266
229
 
267
- {showEditor && <ResizeHandle />}
230
+ {showEditor && !chatHidden && <ResizeHandle />}
268
231
  <Panel panelRef={editorPanelRef} id="editor-panel" defaultSize={editorWidth} minSize={MIN_EDITOR_PANEL_WIDTH} maxSize={editorMaxSize} collapsed={!showEditor}>
269
- <div className="flex w-full h-full" {...drop.dropProps}>
232
+ <div className="flex w-full h-full" {...drop.dropProps} {...drop.captureDropProps}>
270
233
  <EditorDropOverlay visible={drop.isDragging} />
271
234
  <EditorPluginRail
272
235
  activeKey={activeEditorPanelKey}
@@ -3,7 +3,6 @@ import { useSearchParams } from '@/client/lib/router/search-params';
3
3
  import { Group, Panel, type PanelImperativeHandle } from '@/client/components/layout/desktop-layout/resizer';
4
4
  import { LeftColumn } from '@/client/components/layout/desktop-layout/LeftColumn';
5
5
  import { SettingsModal } from '@/client/components/settings/LazyModal';
6
- import type { SettingsCategoryId } from '@/shared/types';
7
6
  import { activeProjectForSession, effectiveRootFor } from '@/shared/lib/workspace/active-project';
8
7
  import { useFileTabs } from '@/client/hooks/workspace/file-tabs';
9
8
  import { useSessionState } from '@/client/hooks/workspace/session-state';
@@ -18,17 +17,17 @@ import { MIN_LEFT_DOCK_PLUGIN_WIDTH } from '@/shared/lib/workspace/left-dock-wid
18
17
  import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
19
18
  import { useOpenPanelRequest } from '@/client/hooks/workspace/open-panel';
20
19
  import { useLeftDock, useLeftDockWidth } from '@/client/hooks/workspace/left-dock';
20
+ import { useEditorViewLayout } from '@/client/hooks/workspace/editor-view';
21
21
  import { useLeftDockView } from '@/client/components/workspace/plugin-panel/catalog';
22
22
  import type { EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
23
- import { useKabmoEvent } from '@/client/hooks/ui/window-event';
23
+ import { useSettingsModal } from '@/client/hooks/ui/settings-modal';
24
24
  import { useWorkspaceKeybindings } from '@/client/hooks/workspace/keybindings';
25
25
  import { PluginBootstrap } from '@/client/lib/plugins/bootstrap';
26
26
  import { useToasts } from '@/client/hooks/ui/toasts';
27
27
  import type { EditorDropResult } from '@/client/hooks/editor/use-editor-drop';
28
28
  import { ToastStack } from '@/client/components/common/ToastStack';
29
29
  import { SessionDeleteModal } from '@/client/components/common/session-delete-modal';
30
- import { useSessionActions } from '@/client/hooks/workspace/session-actions';
31
- import { useSessionDelete } from '@/client/hooks/workspace/session-delete';
30
+ import { useNavbarSessionActions } from '@/client/hooks/workspace/navbar-session-actions';
32
31
 
33
32
  interface DesktopLayoutProps {
34
33
  sessionId: string | null;
@@ -112,32 +111,30 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
112
111
  [openedFiles, activeFileId],
113
112
  );
114
113
 
114
+ /**
115
+ * The editor's WIDTH mode (`compact` / `full`), entered from either toolbar.
116
+ *
117
+ * It collapses columns around the editor and is resolved here, not in the
118
+ * panels, because the sidebar is a sibling of the whole workspace group: a
119
+ * full view that left it standing would not be the width it promises.
120
+ *
121
+ * The mode is derived from `showEditor`, so closing the column returns the
122
+ * chat and the sidebar without writing anything — reopening it comes back to
123
+ * the width the user chose.
124
+ */
125
+ const editorView = useEditorViewLayout(showEditor);
126
+ const sidebarOpen = showLeftPanel && !editorView.sidebarHidden;
127
+
115
128
  // Width restoration is declarative: each panel's `defaultSize` comes from the
116
129
  // remembered width for its current slot and `Panel` adopts a change, so a
117
130
  // view switch needs no imperative push and no cached layout can go stale.
118
131
 
119
- const [settingsOpen, setSettingsOpen] = useState(false);
120
- const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('appearance');
121
- const [autoOpenAddProvider, setAutoOpenAddProvider] = useState(false);
122
132
  const [refreshKey, setRefreshKey] = useState(0);
123
133
  const handleRefreshWorkspace = () => setRefreshKey(k => k + 1);
124
134
  const [sessionTitle, setSessionTitle] = useState<string | null>(null);
125
135
  const streamStatus = useAgentStreamStatus();
136
+ const settings = useSettingsModal();
126
137
 
127
- // The navbar's title menu acts on the session on screen. Its row may live in
128
- // a folder that is not expanded in the sidebar, so the session is found by id
129
- // across every folder rather than by walking the visible list. No row means
130
- // nothing to act on — a session the sidebar does not know (a chat running
131
- // outside every registered workspace) gets no menu rather than dead items.
132
- const activeSession = useMemo(() => {
133
- if (!sessionId) return null;
134
- for (const folder of folders) {
135
- const found = (folder.sessions || []).find((s) => String(s.id) === String(sessionId));
136
- if (found) return found;
137
- }
138
- return null;
139
- }, [folders, sessionId]);
140
- const sidebar = useSidebarData();
141
138
  const { toasts, pushToast, dismissToast } = useToasts();
142
139
 
143
140
  /**
@@ -149,20 +146,19 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
149
146
  (result: EditorDropResult) => pushToast(result.message, result.ok ? 'success' : 'error'),
150
147
  [pushToast],
151
148
  );
152
- const { handleArchive, handleRename, handleRenameWithAi } = useSessionActions(sidebar.refresh, pushToast);
153
- const sessionDelete = useSessionDelete();
154
- // A pending `new-…` chat has no transcript anywhere yet, so rename and delete
155
- // are refused by the server; the menu hides them the way the sidebar does.
156
- const isPendingSession = Boolean(sessionId && sessionId.startsWith('new-'));
157
- const sessionActions = activeSession
158
- ? {
159
- isArchived: activeSession.is_archived === 1,
160
- onRename: isPendingSession ? undefined : (name: string) => void handleRename(activeSession, name),
161
- onRenameWithAi: isPendingSession ? undefined : () => void handleRenameWithAi(activeSession),
162
- onArchive: () => handleArchive(activeSession),
163
- onDelete: isPendingSession ? undefined : () => sessionDelete.requestDelete(activeSession),
164
- }
165
- : undefined;
149
+ const { menu: sessionActions, sessionDelete } = useNavbarSessionActions(folders, sessionId, pushToast);
150
+
151
+ /**
152
+ * Ask for the sidebar (or put it away).
153
+ *
154
+ * Full view hides the sidebar, so asking for it must leave that mode —
155
+ * otherwise the toggle flips a stored flag the layout is ignoring and the
156
+ * click does nothing. `compact` is the mode that keeps the wider editor.
157
+ */
158
+ const handleToggleLeftPanel = (show: boolean) => {
159
+ if (show) editorView.revealSidebar();
160
+ setShowLeftPanel(show);
161
+ };
166
162
 
167
163
  // The workspace shortcuts, from the shared keymap so the toggles' tooltips
168
164
  // name the bound chord. `⌘S` is absent: the editor owns it, and binding it
@@ -170,26 +166,13 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
170
166
  useWorkspaceKeybindings({
171
167
  showEditor,
172
168
  editorHasContent,
173
- toggleSidebar: () => setShowLeftPanel((previous) => !previous),
169
+ // Through the handler, not the raw flag: in full view the sidebar is hidden
170
+ // by the editor's WIDTH mode, so flipping `showLeftPanel` alone would change
171
+ // a value the layout is ignoring and the chord would do nothing.
172
+ toggleSidebar: () => handleToggleLeftPanel(!sidebarOpen),
174
173
  toggleEditor: () => setUserToggledEditor((previous) => !(previous ?? showEditor)),
175
174
  toggleRightPanel: () => setShowRightPanel((previous) => !previous),
176
- openSettings: () => {
177
- setSettingsCategory('appearance');
178
- setAutoOpenAddProvider(false);
179
- setSettingsOpen((previous) => !previous);
180
- },
181
- });
182
-
183
- // Global event listener for settings triggers
184
- useKabmoEvent('omp:open-settings', (e) => {
185
- const customEvent = e as CustomEvent<{ category?: SettingsCategoryId; autoOpenAdd?: boolean }>;
186
- if (customEvent.detail?.category) {
187
- setSettingsCategory(customEvent.detail.category);
188
- } else {
189
- setSettingsCategory('appearance');
190
- }
191
- setAutoOpenAddProvider(!!customEvent.detail?.autoOpenAdd);
192
- setSettingsOpen(true);
175
+ openSettings: () => (settings.isOpen ? settings.close() : settings.open()),
193
176
  });
194
177
 
195
178
  // Clicking the ACTIVE view on an open panel collapses that panel; any other
@@ -200,7 +183,6 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
200
183
  if (!collapsing) setActiveRightPanel(panel);
201
184
  };
202
185
 
203
- const handleToggleLeftPanel = (show: boolean) => setShowLeftPanel(show);
204
186
  const handleToggleRightPanel = () => setShowRightPanel(!showRightPanel);
205
187
  const handleToggleEditor = () => setUserToggledEditor(!showEditor);
206
188
 
@@ -243,7 +225,7 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
243
225
  >
244
226
  <LeftColumn
245
227
  panelRef={leftPanelRef}
246
- open={showLeftPanel}
228
+ open={sidebarOpen}
247
229
  railEnabled={dock.enabled}
248
230
  view={leftView}
249
231
  onSelectView={(panel) => {
@@ -251,7 +233,7 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
251
233
  // toggle would hide the rail the user is aiming at, and the dock
252
234
  // has its own collapse control in the header.
253
235
  setActiveLeftView(panel);
254
- if (!showLeftPanel) handleToggleLeftPanel(true);
236
+ if (!sidebarOpen) handleToggleLeftPanel(true);
255
237
  }}
256
238
  onCollapse={() => handleToggleLeftPanel(false)}
257
239
  width={sidebarWidth}
@@ -272,20 +254,21 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
272
254
  <TopNavbar
273
255
  sessionTitle={sessionTitle}
274
256
  sessionId={sessionId}
275
- showLeftPanel={showLeftPanel}
257
+ showLeftPanel={sidebarOpen}
276
258
  showEditor={showEditor}
277
259
  showRightPanel={showRightPanel}
278
260
  streamStatus={streamStatus}
279
261
  onSwitchToMobile={onSwitchToMobile}
280
262
  onToggleEditor={handleToggleEditor}
281
263
  onToggleRightPanel={handleToggleRightPanel}
282
- onToggleLeftPanel={() => handleToggleLeftPanel(!showLeftPanel)}
264
+ onToggleLeftPanel={() => handleToggleLeftPanel(!sidebarOpen)}
283
265
  sessionActions={sessionActions}
284
266
  />
285
267
 
286
268
  <WorkspacePanels
287
269
  appSettings={appSettings}
288
270
  showEditor={showEditor}
271
+ chatHidden={editorView.chatHidden}
289
272
  editorPanelRef={editorPanelRef}
290
273
  showRightPanel={showRightPanel}
291
274
  activeRightPanel={activeRightPanel}
@@ -321,15 +304,12 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
321
304
  </div>
322
305
 
323
306
  {/* Global Desktop Settings Modal */}
324
- <SettingsModal
325
- isOpen={settingsOpen}
326
- onClose={() => {
327
- setSettingsOpen(false);
328
- setAutoOpenAddProvider(false);
329
- }}
330
- initialCategory={settingsCategory}
331
- autoOpenAddProvider={autoOpenAddProvider}
332
- appSettings={appSettings}
307
+ <SettingsModal
308
+ isOpen={settings.isOpen}
309
+ onClose={settings.close}
310
+ initialCategory={settings.category}
311
+ autoOpenAddProvider={settings.autoOpenAddProvider}
312
+ appSettings={appSettings}
333
313
  />
334
314
 
335
315
  {/* The navbar title menu's delete path and the rename-with-AI refusal
@@ -212,11 +212,14 @@ export function Panel(props: {
212
212
 
213
213
  const baseStyle: CSSProperties = filler
214
214
  ? {
215
- flex: '1 1 0%',
215
+ // A collapsed filler keeps its DOM (and therefore its state: the chat
216
+ // owns the live stream and the composer) but takes no width, so the
217
+ // sibling panels' own widths are the whole row.
218
+ flex: collapsed ? '0 0 0%' : '1 1 0%',
216
219
  // The filler keeps its floor on the group's axis (and none across it,
217
220
  // where it is sized by the container): see the group model note above.
218
- minWidth: orientation === 'horizontal' ? minPx : 0,
219
- minHeight: orientation === 'horizontal' ? 0 : minPx,
221
+ minWidth: orientation === 'horizontal' ? (collapsed ? 0 : minPx) : 0,
222
+ minHeight: orientation === 'horizontal' ? 0 : collapsed ? 0 : minPx,
220
223
  overflow: 'hidden',
221
224
  position: 'relative',
222
225
  }
@@ -1,8 +1,6 @@
1
- import { Download, Info, Settings } from 'lucide-preact';
2
- import {
3
- shellDownloadLabel,
4
- type ShellDownload,
5
- } from '@/client/hooks/ui/updates/shell-download';
1
+ import { Info, Settings } from 'lucide-preact';
2
+ import type { ShellDownload } from '@/client/hooks/ui/updates/shell-download';
3
+ import { ShellUpdateButton } from '@/client/components/common/shell-update-button';
6
4
 
7
5
  interface SessionSidebarFooterProps {
8
6
  onSettings: () => void;
@@ -50,16 +48,11 @@ export function SessionSidebarFooter({ onSettings, onInfo, updateAvailable, onUp
50
48
  // an installed shell that is behind (an update), or an Android browser
51
49
  // whose reader may not know the shell exists (an install). A desktop
52
50
  // browser and an iOS browser get neither — see `useShellDownload`.
53
- <a
54
- href={shellDownload.url}
55
- target="_blank"
56
- rel="noopener noreferrer"
57
- title={shellDownloadLabel(shellDownload)}
58
- aria-label={shellDownloadLabel(shellDownload)}
59
- className="p-1 rounded hover:text-ink hover:bg-ink/5 cursor-pointer transition-colors"
60
- >
61
- <Download size={16} />
62
- </a>
51
+ //
52
+ // A desktop shell that reported its architecture gets a BUTTON: the
53
+ // shell installs the artifact itself, and a link there would be a file
54
+ // in Downloads instead of an updated app.
55
+ <ShellUpdateButton download={shellDownload} variant="icon" />
63
56
  )}
64
57
  </div>
65
58
  {updateAvailable && (
@@ -259,6 +259,7 @@ export function MobileLayoutWrapper({ onDesktopToggle, appSettings = {} }: Mobil
259
259
  <div
260
260
  className={`flex-1 min-h-0 w-full relative ${currentScreen === 'main' ? 'block' : 'hidden'}`}
261
261
  {...editorDrop.dropProps}
262
+ {...editorDrop.captureDropProps}
262
263
  >
263
264
  <EditorDropOverlay visible={editorDrop.isDragging} />
264
265
  <MobileMainView
@@ -69,6 +69,9 @@ export function MobileFullDiff({ diff, onClose, onFileSaved }: MobileFullDiffPro
69
69
  <div className="flex-1 min-h-0">
70
70
  <DiffPanel
71
71
  className="h-full"
72
+ // A full-screen phone overlay has no chat column or sidebar beside
73
+ // it, so the width modes would widen into nothing.
74
+ viewModeButtons={false}
72
75
  filePath={diff.path}
73
76
  status={status}
74
77
  isStaged={isStaged}