ompchamber 0.5.0 → 0.6.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 (76) hide show
  1. package/README.md +46 -1
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/{index.5291f58223.css → index.51f68210a5.css} +1 -1
  4. package/dist/client/static/js/async/{3y.ededeebde8.js → 3y.4c2596f64c.js} +3 -3
  5. package/dist/client/static/js/async/73.308dff4da8.js +1 -0
  6. package/dist/client/static/js/async/{ty.3d93cf5f5f.js → 7g.a7814350c3.js} +1 -1
  7. package/dist/client/static/js/async/a.bce4542392.js +1 -0
  8. package/dist/client/static/js/async/g.8515cbc6f7.js +2 -0
  9. package/dist/client/static/js/async/hn.b645041e00.js +1 -0
  10. package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
  11. package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
  12. package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
  13. package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
  14. package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
  15. package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
  16. package/dist/client/static/js/async/t.cda4891526.js +1 -0
  17. package/dist/client/static/js/async/tr.e878658140.js +309 -0
  18. package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
  19. package/dist/client/static/js/{index.0ac93ee878.js → index.47d3b04d5f.js} +7 -7
  20. package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
  21. package/package.json +13 -5
  22. package/src/cli/lib/args.js +6 -6
  23. package/src/cli/lib/commands/logs.js +76 -34
  24. package/src/cli/lib/commands/restart.js +30 -9
  25. package/src/cli/lib/commands/serve.js +12 -4
  26. package/src/cli/lib/commands/status.js +40 -18
  27. package/src/cli/lib/commands/stop.js +12 -7
  28. package/src/cli/lib/commands/update.js +2 -5
  29. package/src/cli/lib/paths.js +8 -28
  30. package/src/cli/lib/runtime.js +118 -108
  31. package/src/client/components/common/code-editor/index.tsx +5 -1
  32. package/src/client/components/common/code-surface/index.tsx +49 -1
  33. package/src/client/components/common/code-surface/measure.ts +99 -0
  34. package/src/client/components/layout/RightActivityBar.tsx +42 -69
  35. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
  36. package/src/client/components/layout/desktop-layout/index.tsx +11 -9
  37. package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
  38. package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
  39. package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
  40. package/src/client/components/mobile/RightSidebar.tsx +42 -119
  41. package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
  42. package/src/client/components/workspace/editor/index.tsx +4 -1
  43. package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
  44. package/src/client/hooks/editor/use-file-editor.ts +35 -19
  45. package/src/server/index.ts +51 -1
  46. package/src/server/lib/lifecycle/identity.ts +108 -0
  47. package/src/server/lib/lifecycle/instance.ts +102 -0
  48. package/src/server/lib/lifecycle/occupant.ts +101 -0
  49. package/src/server/lib/lifecycle/paths.ts +50 -0
  50. package/src/server/lib/lifecycle/port-guard.ts +170 -0
  51. package/src/server/lib/lifecycle/probe.ts +121 -0
  52. package/src/server/plugins/static.test.ts +63 -0
  53. package/src/server/plugins/static.ts +28 -4
  54. package/src/server/routes/health/probe.ts +8 -0
  55. package/src/shared/lib/markdown/mermaid.ts +8 -0
  56. package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
  57. package/src/shared/lib/workspace/panel-widths.ts +23 -10
  58. package/src/shared/lib/workspace/right-panels.ts +10 -4
  59. package/dist/client/static/js/async/73.50280ce58d.js +0 -1
  60. package/dist/client/static/js/async/a.e05da304c8.js +0 -10
  61. package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
  62. package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
  63. package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
  64. package/dist/client/static/js/async/l.102a97fce6.js +0 -104
  65. package/dist/client/static/js/async/l6.c56640c205.js +0 -1
  66. package/dist/client/static/js/async/nk.6de011832e.js +0 -1
  67. package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
  68. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  69. package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
  70. /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
  71. /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.8515cbc6f7.js.LICENSE.txt} +0 -0
  72. /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
  73. /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
  74. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
  75. /package/dist/client/static/js/{index.0ac93ee878.js.LICENSE.txt → index.47d3b04d5f.js.LICENSE.txt} +0 -0
  76. /package/dist/client/static/js/{uy.5f791144ce.js.LICENSE.txt → uy.2e8929ed0c.js.LICENSE.txt} +0 -0
@@ -6,8 +6,8 @@ import { ChatTimeline } from '@/client/components/workspace/chat-timeline/index'
6
6
  import { getDesktopPanelView } from '@/client/components/common/lazy-panels';
7
7
  import { RightActivityBar } from '@/client/components/layout/RightActivityBar';
8
8
  import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
9
- import { DEFAULT_RIGHT_PANEL_WIDTHS, MAX_RIGHT_PANEL_WIDTH, MIN_RIGHT_PANEL_WIDTHS, type RightPanelType } from '@/shared/lib/workspace/right-panels';
10
- import { DEFAULT_PANEL_WIDTHS, type EditorWidthMode, type PanelWidths } from '@/shared/lib/workspace/panel-widths';
9
+ import { DEFAULT_RIGHT_PANEL_WIDTHS, MAX_RIGHT_PANEL_WIDTH, MIN_RIGHT_PANEL_WIDTHS, RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
10
+ import { DEFAULT_PANEL_WIDTHS, MAX_EDITOR_PANEL_WIDTH, MIN_CHAT_PANEL_WIDTH, MIN_EDITOR_PANEL_WIDTH, type EditorWidthMode, type PanelWidths } from '@/shared/lib/workspace/panel-widths';
11
11
 
12
12
  const Editor = lazy(() => import('@/client/components/workspace/editor/index').then((m) => ({ default: m.Editor })));
13
13
 
@@ -82,8 +82,7 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
82
82
  // and no panel re-fetches its data just because the user looked away.
83
83
  const viewScope = useMemo(() => ({}), []);
84
84
  const rightViews = useMemo(
85
- () => (['files', 'search', 'git', 'terminal', 'context', 'user-browser', 'browser', 'usage'] as const)
86
- .map((view) => ({ view, Comp: getDesktopPanelView(viewScope, view) })),
85
+ () => RIGHT_PANEL_TYPES.map((view) => ({ view, Comp: getDesktopPanelView(viewScope, view) })),
87
86
  [viewScope],
88
87
  );
89
88
 
@@ -114,14 +113,14 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
114
113
  onPanelWidths(readPanelWidths());
115
114
  }}
116
115
  >
117
- <Panel id="center-panel" filler minSize="540px">
116
+ <Panel id="center-panel" filler minSize={MIN_CHAT_PANEL_WIDTH}>
118
117
  <ChatTimeline className="w-full h-full" appSettings={appSettings} onSessionTitle={onSessionTitle} />
119
118
  </Panel>
120
119
 
121
120
  {showEditor && (
122
121
  <>
123
122
  <ResizeHandle />
124
- <Panel panelRef={editorPanelRef} id="editor-panel" defaultSize={panelWidths[editorWidthMode] ?? DEFAULT_PANEL_WIDTHS[editorWidthMode]} minSize={300}>
123
+ <Panel panelRef={editorPanelRef} id="editor-panel" defaultSize={panelWidths[editorWidthMode] ?? DEFAULT_PANEL_WIDTHS[editorWidthMode]} minSize={MIN_EDITOR_PANEL_WIDTH} maxSize={MAX_EDITOR_PANEL_WIDTH}>
125
124
  <PanelSuspense>
126
125
  <Editor
127
126
  className="w-full h-full"
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
3
  import { useSearchParams } from '@/client/lib/router/search-params';
4
4
  import { Group, Panel, type PanelImperativeHandle } from '@/client/components/layout/desktop-layout/resizer';
5
5
  import { SessionSidebar } from '@/client/components/layout/session-sidebar/index';
6
- import { type RightPanelType } from '@/shared/lib/workspace/right-panels';
6
+ import { DEFAULT_RIGHT_PANEL_WIDTHS, type RightPanelType } from '@/shared/lib/workspace/right-panels';
7
7
  import { SettingsModal } from '@/client/components/settings/LazyModal';
8
8
  import { PanelLeft } from 'lucide-preact';
9
9
  import type { SettingsCategoryId } from '@/shared/types';
@@ -15,7 +15,7 @@ import { WorkspacePanels } from '@/client/components/layout/desktop-layout/Works
15
15
  import { useAgentStreamStatus } from '@/client/hooks/chat/omp/status';
16
16
  import { usePanelWidths } from '@/client/hooks/workspace/panel-widths';
17
17
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
18
- import { DEFAULT_PANEL_WIDTHS, type EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
18
+ import { DEFAULT_PANEL_WIDTHS, MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH, type EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
19
19
  import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
20
20
  import { writeSetting } from '@/shared/lib/settings/client';
21
21
  import { useChamberEvent, useWindowEvent } from '@/client/hooks/ui/window-event';
@@ -99,21 +99,23 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
99
99
  }, [showLeftPanel, widthsRef]);
100
100
 
101
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. The
103
- // width the incoming view remembers must be pushed on imperatively; a
104
- // toggle-on remount already derives from defaultSize, making this a no-op.
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.
105
106
  useEffect(() => {
106
107
  const frame = requestAnimationFrame(() => {
107
- applyWidth(rightPanelRef, widthsRef.current?.right?.[activeRightPanel]);
108
+ applyWidth(rightPanelRef, widthsRef.current?.right?.[activeRightPanel] ?? DEFAULT_RIGHT_PANEL_WIDTHS[activeRightPanel]);
108
109
  });
109
110
  return () => cancelAnimationFrame(frame);
110
111
  }, [showRightPanel, activeRightPanel, widthsRef]);
111
112
 
112
113
  // Same story for the editor across a source↔diff tab switch: the panel
113
- // stays mounted, so the width its tab kind remembers is re-applied here.
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.
114
116
  useEffect(() => {
115
117
  const frame = requestAnimationFrame(() => {
116
- applyWidth(editorPanelRef, widthsRef.current?.[editorWidthMode]);
118
+ applyWidth(editorPanelRef, widthsRef.current?.[editorWidthMode] ?? DEFAULT_PANEL_WIDTHS[editorWidthMode]);
117
119
  });
118
120
  return () => cancelAnimationFrame(frame);
119
121
  }, [showEditor, editorWidthMode, widthsRef]);
@@ -215,7 +217,7 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
215
217
  >
216
218
  {showLeftPanel && (
217
219
  <>
218
- <Panel panelRef={leftPanelRef} id="left-panel" defaultSize={panelWidths.left ?? DEFAULT_PANEL_WIDTHS.left} minSize={200} maxSize={600} collapsible>
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>
219
221
  <SessionSidebar className="w-full h-full" onClose={() => handleToggleLeftPanel(false)} appSettings={appSettings} />
220
222
  </Panel>
221
223
  <ResizeHandle />
@@ -2,18 +2,25 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState } from
2
2
  import { useRef as useRefBase } from 'preact/hooks';
3
3
  import type { ComponentChildren, RefObject } from 'preact';
4
4
  import type { CSSProperties, TargetedPointerEvent } from 'preact';
5
+ import { clamp, toPx, transfer } from '@/client/components/layout/desktop-layout/resizer/utils';
5
6
 
6
7
  /**
7
8
  * Hand-rolled resizable panel group (the Preact-native replacement for
8
9
  * react-resizable-panels in this app's two horizontal layouts).
9
10
  *
10
- * Model: every Panel is a flex child sized by `flex: 0 0 auto` plus a px
11
+ * Model: every Panel is a flex child sized by `flex: 0 1 auto` plus a px
11
12
  * width/height held in state; exactly one panel per group is the FILLER
12
13
  * (`filler` prop) and takes `flex: 1 1 0`, absorbing the remaining space. A
13
14
  * Separator drag transfers pixels between the two panels adjacent to it,
14
15
  * clamped by both neighbors' min/max constraints, and commits once per drag
15
16
  * through `onLayoutChanged(_, { isUserInteraction: true })`.
16
17
  *
18
+ * The filler's `minSize` is a real CSS floor, which is what makes a shrinking
19
+ * window push the resizable panels toward their own floors instead of
20
+ * collapsing the filler to nothing (a zero flex basis absorbs no negative free
21
+ * space). Once every panel sits at its floor the row is narrower than its
22
+ * container and the surplus is clipped — the layout has no panel to give up.
23
+ *
17
24
  * The imperative API mirrors what this app used from the old library:
18
25
  * `panelRef.resize(px)`, `panelRef.getSize() -> { inPixels }`, and the
19
26
  * group's `setLayout` as a batch px resize.
@@ -59,31 +66,6 @@ interface GroupContextValue {
59
66
 
60
67
  const GroupContext = createContext<GroupContextValue | null>(null);
61
68
 
62
- function clamp(value: number, min: number, max: number): number {
63
- return Math.min(Math.max(value, min), max);
64
- }
65
-
66
- function toPx(value: number | string | undefined, fallback: number): number {
67
- if (typeof value === 'number') return value;
68
- if (typeof value === 'string') return parseFloat(value) || fallback;
69
- return fallback;
70
- }
71
-
72
- /**
73
- * Pixels transferred between the dragged pair. Positive delta moves the
74
- * separator right/down: A grows, B shrinks. The applied delta is clamped so
75
- * neither panel crosses its own min/max.
76
- */
77
- function transfer(
78
- a: { size: number; min: number; max: number },
79
- b: { size: number; min: number; max: number },
80
- delta: number,
81
- ): number {
82
- const lower = Math.max(a.min - a.size, b.min - b.size);
83
- const upper = Math.min(a.max - a.size, b.size - b.min);
84
- return clamp(delta, lower, upper);
85
- }
86
-
87
69
  export function Group(props: {
88
70
  orientation: GroupOrientation;
89
71
  id?: string;
@@ -219,7 +201,15 @@ export function Panel(props: {
219
201
  }, [panelRef, minPx, maxPx]);
220
202
 
221
203
  const baseStyle: CSSProperties = filler
222
- ? { flex: '1 1 0%', minWidth: 0, minHeight: 0, overflow: 'hidden', position: 'relative' }
204
+ ? {
205
+ flex: '1 1 0%',
206
+ // The filler keeps its floor on the group's axis (and none across it,
207
+ // where it is sized by the container): see the group model note above.
208
+ minWidth: orientation === 'horizontal' ? minPx : 0,
209
+ minHeight: orientation === 'horizontal' ? 0 : minPx,
210
+ overflow: 'hidden',
211
+ position: 'relative',
212
+ }
223
213
  : {
224
214
  flex: '0 1 auto',
225
215
  width: orientation === 'horizontal' ? sizePx ?? undefined : undefined,
@@ -280,8 +270,11 @@ export function Separator(props: { className?: string; style?: CSSProperties; ch
280
270
  const startPos = orientation === 'horizontal' ? e.clientX : e.clientY;
281
271
  const measure = (el: HTMLElement) =>
282
272
  orientation === 'horizontal' ? el.getBoundingClientRect().width : el.getBoundingClientRect().height;
283
- const aStart = a.getSize() ?? measure(previous);
284
- const bStart = b.getSize() ?? measure(next);
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);
285
278
 
286
279
  const onMove = (ev: PointerEvent) => {
287
280
  const pos = orientation === 'horizontal' ? ev.clientX : ev.clientY;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { transfer } from '@/client/components/layout/desktop-layout/resizer/utils';
9
+
10
+ describe('transfer', () => {
11
+ test('growing the leading panel stops at its own ceiling', () => {
12
+ expect(transfer({ size: 600, min: 300, max: 1200 }, { size: 1200, min: 320, max: 1200 }, 900)).toBe(600);
13
+ });
14
+
15
+ test('growing the leading panel stops at the trailing floor', () => {
16
+ expect(transfer({ size: 600, min: 300, max: 1200 }, { size: 268, min: 200, max: 1200 }, 900)).toBe(68);
17
+ });
18
+
19
+ test('shrinking the leading panel stops at its own floor', () => {
20
+ expect(transfer({ size: 420, min: 420, max: Number.MAX_SAFE_INTEGER }, { size: 300, min: 300, max: 1200 }, -500)).toBe(0);
21
+ });
22
+
23
+ test('a trailing panel parked on its floor still lets the leading one shrink', () => {
24
+ // The editor sat at its 300px floor, which used to clamp every negative
25
+ // delta to 0: the separator went dead and the layout could not be dragged
26
+ // back out of that state.
27
+ expect(transfer({ size: 1520, min: 420, max: Number.MAX_SAFE_INTEGER }, { size: 300, min: 300, max: 1200 }, -1600)).toBe(-900);
28
+ });
29
+
30
+ test('a delta inside both panels has room passes through', () => {
31
+ expect(transfer({ size: 600, min: 300, max: 1200 }, { size: 640, min: 320, max: 1200 }, -100)).toBe(-100);
32
+ });
33
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Pure geometry for the resizable panel group: the clamping, the px parsing
3
+ * and the pixel transfer between a dragged pair. Kept out of `index.tsx` so
4
+ * the components stay inside the repo's file-size ceiling.
5
+ */
6
+
7
+ export function clamp(value: number, min: number, max: number): number {
8
+ return Math.min(Math.max(value, min), max);
9
+ }
10
+
11
+ export function toPx(value: number | string | undefined, fallback: number): number {
12
+ if (typeof value === 'number') return value;
13
+ if (typeof value === 'string') return parseFloat(value) || fallback;
14
+ return fallback;
15
+ }
16
+
17
+ /**
18
+ * Pixels transferred between the dragged pair. Positive delta moves the
19
+ * separator right/down: A grows, B shrinks. The applied delta is clamped so
20
+ * neither panel crosses its own min/max.
21
+ *
22
+ * A shrinking is bounded by A's floor and B's *ceiling* — B is the panel that
23
+ * grows on that side. Bounding it by B's floor instead is what made a
24
+ * separator dead as soon as its neighbour sat at its minimum.
25
+ */
26
+ export function transfer(
27
+ a: { size: number; min: number; max: number },
28
+ b: { size: number; min: number; max: number },
29
+ delta: number,
30
+ ): number {
31
+ const lower = Math.max(a.min - a.size, b.size - b.max);
32
+ const upper = Math.min(a.max - a.size, b.size - b.min);
33
+ return clamp(delta, lower, upper);
34
+ }
@@ -1,9 +1,11 @@
1
1
  import { useState } from 'preact/hooks';
2
2
  import { Suspense } from 'preact/compat';
3
+ import type { ReactNode } from 'preact/compat';
3
4
  import { BarChart3, Bot, Files, GitBranch, Globe, Layers, Search, Terminal, X } from 'lucide-preact';
4
5
  import { LazyBrowserPanel, LazyContextPanel, LazyFileExplorer, LazyGitPanel, LazySearchPanel, LazyTerminalPanel, LazyUsagePanel, LazyUserBrowserPanel } from '@/client/components/common/lazy-panels';
5
6
  import { useGitStatus } from '@/client/hooks/workspace/git-status';
6
7
  import { GIT_STATUS_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
8
+ import { RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
7
9
 
8
10
  interface MobileRightSidebarProps {
9
11
  enabled?: boolean;
@@ -15,7 +17,21 @@ interface MobileRightSidebarProps {
15
17
  onClose: () => void;
16
18
  }
17
19
 
18
- type MobileTab = 'git' | 'files' | 'search' | 'context' | 'terminal' | 'user-browser' | 'browser' | 'usage';
20
+ /**
21
+ * Wording per view: the title is the desktop activity bar's, the label is the
22
+ * short chip the phone shows beside the icon. The *order* is not restated here
23
+ * — it comes from RIGHT_PANEL_TYPES, so both layouts list the views the same way.
24
+ */
25
+ const PANEL_META: Record<RightPanelType, { title: string; label: string; icon: ReactNode }> = {
26
+ context: { title: 'Context & Telemetry', label: 'Context', icon: <Layers size={14} className="flex-shrink-0" /> },
27
+ files: { title: 'Files Explorer', label: 'Files', icon: <Files size={14} className="flex-shrink-0" /> },
28
+ search: { title: 'Search Workspace', label: 'Search', icon: <Search size={14} className="flex-shrink-0" /> },
29
+ git: { title: 'GIT', label: 'GIT', icon: <GitBranch size={14} className="flex-shrink-0" /> },
30
+ terminal: { title: 'Bun Terminal', label: 'Terminal', icon: <Terminal size={14} className="flex-shrink-0" /> },
31
+ 'user-browser': { title: 'Browser (Anda)', label: 'Browser', icon: <Globe size={14} className="flex-shrink-0" /> },
32
+ browser: { title: 'Browser Agent', label: 'Agent', icon: <Bot size={14} className="flex-shrink-0" /> },
33
+ usage: { title: 'Usage', label: 'Usage', icon: <BarChart3 size={14} className="flex-shrink-0" /> },
34
+ };
19
35
 
20
36
  export function MobileRightSidebar({
21
37
  enabled = true,
@@ -25,7 +41,7 @@ export function MobileRightSidebar({
25
41
  onOpenFile,
26
42
  onClose
27
43
  }: MobileRightSidebarProps) {
28
- const [activeTab, setActiveTab] = useState<MobileTab>('files');
44
+ const [activeTab, setActiveTab] = useState<RightPanelType>('files');
29
45
  // Same source the desktop activity bar uses, so the "uncommitted changes"
30
46
  // dot means the same thing on both layouts. Polls only while this drawer is
31
47
  // the mounted screen — the poll itself is visibility-gated by the hook.
@@ -47,123 +63,30 @@ export function MobileRightSidebar({
47
63
  >
48
64
 
49
65
  <div className="flex items-center space-x-1.5 overflow-x-auto no-scrollbar py-1">
50
- <button
51
- type="button"
52
- onClick={() => setActiveTab('files')}
53
- className={`flex items-center transition-all cursor-pointer ${
54
- activeTab === 'files'
55
- ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
56
- : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
57
- }`}
58
- title="Files Explorer"
59
- >
60
- <Files size={14} className="flex-shrink-0" />
61
- {activeTab === 'files' && <span>Files</span>}
62
- </button>
63
-
64
- <button
65
- type="button"
66
- onClick={() => setActiveTab('search')}
67
- className={`flex items-center transition-all cursor-pointer ${
68
- activeTab === 'search'
69
- ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
70
- : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
71
- }`}
72
- title="Search Workspace"
73
- >
74
- <Search size={14} className="flex-shrink-0" />
75
- {activeTab === 'search' && <span>Search</span>}
76
- </button>
77
-
78
- <button
79
- type="button"
80
- onClick={() => setActiveTab('git')}
81
- className={`relative flex items-center transition-all cursor-pointer ${
82
- activeTab === 'git'
83
- ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
84
- : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
85
- }`}
86
- title="GIT"
87
- >
88
- <GitBranch size={14} className="flex-shrink-0" />
89
- {activeTab === 'git' && <span className="tracking-wide">GIT</span>}
90
- {hasGitChanges && (
91
- <span
92
- className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-info"
93
- title="Uncommitted changes"
94
- />
95
- )}
96
- </button>
97
-
98
- <button
99
- type="button"
100
- onClick={() => setActiveTab('context')}
101
- className={`flex items-center transition-all cursor-pointer ${
102
- activeTab === 'context'
103
- ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
104
- : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
105
- }`}
106
- title="Context & Telemetry"
107
- >
108
- <Layers size={14} className="flex-shrink-0" />
109
- {activeTab === 'context' && <span>Context</span>}
110
- </button>
111
-
112
- <button
113
- type="button"
114
- onClick={() => setActiveTab('terminal')}
115
- className={`flex items-center transition-all cursor-pointer ${
116
- activeTab === 'terminal'
117
- ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
118
- : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
119
- }`}
120
- title="Bun Terminal"
121
- >
122
- <Terminal size={14} className="flex-shrink-0" />
123
- {activeTab === 'terminal' && <span>Terminal</span>}
124
- </button>
125
-
126
- <button
127
- type="button"
128
- onClick={() => setActiveTab('user-browser')}
129
- className={`flex items-center transition-all cursor-pointer ${
130
- activeTab === 'user-browser'
131
- ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
132
- : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
133
- }`}
134
- title="Browser (Anda)"
135
- >
136
- <Globe size={14} className="flex-shrink-0" />
137
- {activeTab === 'user-browser' && <span>Browser</span>}
138
- </button>
139
-
140
- <button
141
- type="button"
142
- onClick={() => setActiveTab('browser')}
143
- className={`flex items-center transition-all cursor-pointer ${
144
- activeTab === 'browser'
145
- ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
146
- : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
147
- }`}
148
- title="Browser Agent"
149
- >
150
- <Bot size={14} className="flex-shrink-0" />
151
- {activeTab === 'browser' && <span>Agent</span>}
152
- </button>
153
-
154
- <button
155
- type="button"
156
- onClick={() => setActiveTab('usage')}
157
- className={`flex items-center transition-all cursor-pointer ${
158
- activeTab === 'usage'
159
- ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
160
- : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
161
- }`}
162
- title="Usage"
163
- >
164
- <BarChart3 size={14} className="flex-shrink-0" />
165
- {activeTab === 'usage' && <span>Usage</span>}
166
- </button>
66
+ {RIGHT_PANEL_TYPES.map((panel) => (
67
+ <button
68
+ key={panel}
69
+ type="button"
70
+ onClick={() => setActiveTab(panel)}
71
+ className={`relative flex items-center transition-all cursor-pointer ${
72
+ activeTab === panel
73
+ ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
74
+ : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
75
+ }`}
76
+ title={PANEL_META[panel].title}
77
+ >
78
+ {PANEL_META[panel].icon}
79
+ {activeTab === panel && (
80
+ <span className={panel === 'git' ? 'tracking-wide' : undefined}>{PANEL_META[panel].label}</span>
81
+ )}
82
+ {panel === 'git' && hasGitChanges && (
83
+ <span
84
+ className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-info"
85
+ title="Uncommitted changes"
86
+ />
87
+ )}
88
+ </button>
89
+ ))}
167
90
  </div>
168
91
 
169
92
  <button
@@ -152,8 +152,24 @@ export function MobileHeader({
152
152
  <ChevronDown size={13} className="text-ink/50 flex-shrink-0" />
153
153
  </button>
154
154
 
155
+ {/* Pinned to the viewport, not to the trigger: the trigger sits
156
+ ~155px from the left edge, so a 288px panel anchored at its
157
+ `left-0` ran off the right edge of a phone screen. Fixed with
158
+ both insets keeps it inside the safe area at any width. */}
155
159
  {showSessionPicker && (
156
- <div className="absolute top-full left-0 mt-1.5 w-72 max-w-[calc(100vw-1.5rem)] bg-paper border border-ink/15 rounded-xl shadow-lg z-50 p-2 text-xs">
160
+ <div
161
+ className="fixed z-50 bg-paper border border-ink/15 rounded-xl shadow-lg p-2 text-xs"
162
+ style={{
163
+ top: 'calc(3.5rem + env(safe-area-inset-top, 0px) + 0.375rem)',
164
+ left: 'max(0.75rem, env(safe-area-inset-left, 0px))',
165
+ right: 'max(0.75rem, env(safe-area-inset-right, 0px))',
166
+ maxWidth: '18rem',
167
+ // A landscape phone is only ~390px tall; without this the list
168
+ // ran past the bottom edge the same way it ran past the right.
169
+ maxHeight: 'calc(100dvh - 3.5rem - env(safe-area-inset-top, 0px) - 1.125rem)',
170
+ overflowY: 'auto',
171
+ }}
172
+ >
157
173
  <button
158
174
  type="button"
159
175
  onClick={() => { onNewSession(); setShowSessionPicker(false); }}
@@ -165,7 +165,10 @@ export function Editor({
165
165
  fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
166
166
  }}
167
167
  gutterLineClassName="min-w-[1.5rem]"
168
- editorWrapperClassName="flex-1 min-w-max code-surface"
168
+ // `min-w-max` keeps a long line intact and lets the panel
169
+ // scroll sideways; while wrapping it would instead widen the
170
+ // column past the panel, so the toggle had no effect at all.
171
+ editorWrapperClassName={`flex-1 code-surface ${wordWrap ? 'min-w-0' : 'min-w-max'}`}
169
172
  editorPadding={16}
170
173
  editorClassName="font-mono focus:outline-none"
171
174
  editorStyle={{
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `useFileEditor` revalidation contract.
8
+ *
9
+ * A landed write bumps the workspace refreshKey, which calls `reset()`. Reset
10
+ * used to drop every cached buffer, so the very next render painted an empty
11
+ * editor — and because the read effect only re-runs on a key/identity change,
12
+ * the file never came back without a full page reload. The same window let a
13
+ * read response that was already in flight land on top of keystrokes typed
14
+ * after it was requested.
15
+ *
16
+ * These run against a real DOM (happy-dom) because the hook is a Preact hook
17
+ * driving fetches and effects: the modules below are imported inside
18
+ * `beforeAll`, after those globals exist, because Preact and the highlighter
19
+ * bind their environment at evaluation time.
20
+ */
21
+
22
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
23
+ import { Window } from 'happy-dom';
24
+ import type { h as PreactH, render as PreactRender } from 'preact';
25
+ import type { act as PreactAct } from 'preact/test-utils';
26
+ import type { UseFileEditorResult, useFileEditor as UseFileEditorHook } from '@/client/hooks/editor/use-file-editor';
27
+
28
+ const DISK = 'const a = 1;\nconst b = 2;\n';
29
+ /** Stable identity: the read effect re-runs when its target object changes. */
30
+ const TARGET = { id: 1, name: 'a.js', path: 'a.js' };
31
+
32
+ let useFileEditor: typeof UseFileEditorHook;
33
+ let h: typeof PreactH;
34
+ let render: typeof PreactRender;
35
+ let act: typeof PreactAct;
36
+
37
+ /** Latest render's result — re-read after every settle, never held across one. */
38
+ let mounted: UseFileEditorResult | null = null;
39
+ let container: HTMLElement;
40
+
41
+ interface FsState {
42
+ reads: number;
43
+ writes: number;
44
+ body: string;
45
+ }
46
+
47
+ /** Serves `state.body` for reads and counts writes, without touching the network. */
48
+ function stubFetch(state: FsState) {
49
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
50
+ if (String(input).startsWith('/api/fs/read')) {
51
+ state.reads += 1;
52
+ return { ok: true, json: async () => ({ content: state.body }) };
53
+ }
54
+ state.writes += 1;
55
+ state.body = String((init?.body as FormData).get('content'));
56
+ return { ok: true, json: async () => ({ success: true }) };
57
+ }) as unknown as typeof fetch;
58
+ }
59
+
60
+ /**
61
+ * Lets the stub's promise chain run and the render it queues land. The chain
62
+ * needs no timers (nothing I/O bound), and Preact's `act` only flushes queued
63
+ * renders on its boundary, so both get a pass.
64
+ */
65
+ async function settleEditor() {
66
+ for (let i = 0; i < 20; i++) await Promise.resolve();
67
+ await act(async () => {
68
+ for (let i = 0; i < 20; i++) await Promise.resolve();
69
+ });
70
+ }
71
+
72
+ async function mountEditor() {
73
+ const Probe = () => {
74
+ mounted = useFileEditor(TARGET);
75
+ return null;
76
+ };
77
+ await act(async () => {
78
+ render(h(Probe, {}), container);
79
+ });
80
+ await settleEditor();
81
+ }
82
+
83
+ /** The DOM surface this file installs for the hook and Preact to render into. */
84
+ function domGlobals(win: Window): Record<string, unknown> {
85
+ return {
86
+ window: win,
87
+ document: win.document,
88
+ navigator: win.navigator,
89
+ Node: win.Node,
90
+ Element: win.Element,
91
+ HTMLElement: win.HTMLElement,
92
+ };
93
+ }
94
+
95
+ const installed: Record<string, unknown> = {};
96
+ const displaced: Record<string, unknown> = {};
97
+
98
+ beforeAll(async () => {
99
+ const win = new Window({ url: 'http://localhost' });
100
+ for (const [key, value] of Object.entries(domGlobals(win))) {
101
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
102
+ installed[key] = value;
103
+ (globalThis as Record<string, unknown>)[key] = value;
104
+ }
105
+ ({ useFileEditor } = await import('@/client/hooks/editor/use-file-editor'));
106
+ ({ h, render } = await import('preact'));
107
+ ({ act } = await import('preact/test-utils'));
108
+ });
109
+
110
+ // Every test file shares one process, so the globals go back the way they were:
111
+ // code that branches on `typeof window` (the agent-event fold, for one) would
112
+ // otherwise take its browser path for the rest of the run.
113
+ afterAll(() => {
114
+ for (const key of Object.keys(installed)) {
115
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
116
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
117
+ }
118
+ });
119
+
120
+ beforeEach(() => {
121
+ mounted = null;
122
+ container = document.createElement('div');
123
+ document.body.appendChild(container);
124
+ });
125
+
126
+ describe('useFileEditor revalidation', () => {
127
+ test('reset re-reads from disk without blanking the open file', async () => {
128
+ const state: FsState = { reads: 0, writes: 0, body: DISK };
129
+ stubFetch(state);
130
+ await mountEditor();
131
+ expect(mounted?.content).toBe(DISK);
132
+
133
+ // The refresh a landed write triggers, with newer bytes now on disk.
134
+ state.body = `${DISK}const c = 3;\n`;
135
+ await act(async () => {
136
+ mounted?.reset();
137
+ });
138
+ await settleEditor();
139
+
140
+ expect(state.reads).toBe(2);
141
+ expect(mounted?.content).toBe(state.body);
142
+ });
143
+
144
+ test('a read that lands mid-typing does not replace the buffer', async () => {
145
+ const state: FsState = { reads: 0, writes: 0, body: DISK };
146
+ stubFetch(state);
147
+ await mountEditor();
148
+
149
+ await act(async () => {
150
+ mounted?.onChange('edited by hand');
151
+ mounted?.reset();
152
+ });
153
+ await settleEditor();
154
+
155
+ expect(state.reads).toBe(2);
156
+ expect(mounted?.content).toBe('edited by hand');
157
+ });
158
+ });