ompchamber 0.5.0 → 0.7.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 (89) hide show
  1. package/README.md +67 -6
  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.86e2256a70.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.eccad0b70a.js +33 -0
  20. package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
  21. package/package.json +15 -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 +40 -8
  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 +23 -12
  29. package/src/cli/lib/paths.js +8 -28
  30. package/src/cli/lib/runtime.js +121 -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/chat/omp/index.ts +4 -109
  44. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  45. package/src/client/hooks/chat/timeline/send.ts +3 -2
  46. package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
  47. package/src/client/hooks/editor/use-file-editor.ts +35 -19
  48. package/src/server/index.ts +52 -1
  49. package/src/server/lib/lifecycle/bun.ts +18 -0
  50. package/src/server/lib/lifecycle/identity.ts +108 -0
  51. package/src/server/lib/lifecycle/instance.ts +102 -0
  52. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  53. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  54. package/src/server/lib/lifecycle/occupant.ts +101 -0
  55. package/src/server/lib/lifecycle/paths.ts +50 -0
  56. package/src/server/lib/lifecycle/port-guard.ts +170 -0
  57. package/src/server/lib/lifecycle/probe.ts +121 -0
  58. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  59. package/src/server/lib/lifecycle/restart.ts +111 -0
  60. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  61. package/src/server/lib/updates/apply.ts +8 -5
  62. package/src/server/lib/updates/install.ts +7 -4
  63. package/src/server/plugins/static.test.ts +63 -0
  64. package/src/server/plugins/static.ts +28 -4
  65. package/src/server/routes/health/probe.ts +9 -0
  66. package/src/shared/lib/markdown/mermaid.ts +8 -0
  67. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  68. package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
  69. package/src/shared/lib/workspace/panel-widths.ts +23 -10
  70. package/src/shared/lib/workspace/right-panels.ts +10 -4
  71. package/dist/client/static/js/async/73.50280ce58d.js +0 -1
  72. package/dist/client/static/js/async/a.e05da304c8.js +0 -10
  73. package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
  74. package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
  75. package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
  76. package/dist/client/static/js/async/l.102a97fce6.js +0 -104
  77. package/dist/client/static/js/async/l6.c56640c205.js +0 -1
  78. package/dist/client/static/js/async/nk.6de011832e.js +0 -1
  79. package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
  80. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  81. package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
  82. package/dist/client/static/js/index.0ac93ee878.js +0 -33
  83. /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
  84. /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.86e2256a70.js.LICENSE.txt} +0 -0
  85. /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
  86. /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
  87. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
  88. /package/dist/client/static/js/{index.0ac93ee878.js.LICENSE.txt → index.eccad0b70a.js.LICENSE.txt} +0 -0
  89. /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={{
@@ -1,8 +1,8 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
2
  import type { ChatMessageData, OmpAgentCallbacks, OmpAgentHandle, OmpAgentState, StreamTransport } from '@/shared/types';
3
3
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
4
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
5
4
  import { type ToolResultRecord, useOmpAgentStream } from '@/client/hooks/chat/omp/stream';
5
+ import { useOmpPromptSender } from '@/client/hooks/chat/omp/prompt-send';
6
6
  import { stopAgentSession } from '@/shared/lib/chat/omp/abort';
7
7
 
8
8
  /**
@@ -98,114 +98,9 @@ export function useOmpAgent(sessionId: string | null, callbacks: OmpAgentCallbac
98
98
  };
99
99
  }, [sessionId, disconnect, connect]);
100
100
 
101
- /** Send a prompt to the omp session via the RPC bridge. */
102
- const sendPrompt = useCallback(async (
103
- message: string,
104
- images?: { data: string; mimeType: string }[],
105
- options?: { accessMode?: ApprovalMode },
106
- ) => {
107
- const sid = sessionIdRef.current;
108
- if (!sid) return false;
109
- setState((prev) => ({ ...prev, isGenerating: true, error: null }));
110
- try {
111
- // Mirror omp-web handleSend: warm the session process up with get_state
112
- // (spawns it on first use), then attach the event stream before sending
113
- // the prompt so no agent events are missed.
114
- const warmup = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
115
- method: 'POST',
116
- headers: { 'Content-Type': 'application/json' },
117
- // The mode rides the warmup too: this is the request that lazily spawns
118
- // an idle session, and omp only accepts --approval-mode at spawn time.
119
- body: JSON.stringify({
120
- type: 'get_state',
121
- ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
122
- }),
123
- });
124
- if (warmup.ok) connect(sid);
125
-
126
- const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
127
- method: 'POST',
128
- headers: { 'Content-Type': 'application/json' },
129
- body: JSON.stringify({
130
- type: 'prompt',
131
- message,
132
- ...(images?.length ? { images } : {}),
133
- ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
134
- }),
135
- });
136
- const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
137
- if (!res.ok || body.error) {
138
- // `session_busy`: the ack timed out behind a still-running turn, so the
139
- // prompt may already be accepted — never resend it automatically.
140
- setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
141
- return false;
142
- }
143
- return true;
144
- } catch (e) {
145
- setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
146
- return false;
147
- }
148
- }, [connect]);
149
-
150
- /** Spawn a brand-new omp session and send the first prompt: ensure_session
151
- * first (returns omp's real session id), attach the event stream, then send
152
- * the prompt through the existing session route so no agent events are
153
- * missed. Model/thinking picks ride the ensure_session body so omp applies
154
- * them BEFORE the first prompt (and their JSONL change entries are written
155
- * up front, not after the run starts). Returns the new session id on
156
- * success, or null on failure — the caller adopts the id as the active
157
- * session. */
158
- const sendNewPrompt = useCallback(async (
159
- message: string,
160
- cwd: string,
161
- images?: { data: string; mimeType: string }[],
162
- composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
163
- ): Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null> => {
164
- setState((prev) => ({ ...prev, isGenerating: true, error: null }));
165
- try {
166
- const created = await fetch('/api/agent/new', {
167
- method: 'POST',
168
- headers: { 'Content-Type': 'application/json' },
169
- body: JSON.stringify({
170
- type: 'ensure_session',
171
- cwd,
172
- ...(composerOptions?.model ? composerOptions.model : {}),
173
- ...(composerOptions?.thinkingLevel ? { thinkingLevel: composerOptions.thinkingLevel } : {}),
174
- ...(composerOptions?.accessMode ? { accessMode: composerOptions.accessMode } : {}),
175
- }),
176
- });
177
- const createdBody = (await created.json().catch(() => ({}))) as {
178
- success?: boolean;
179
- sessionId?: string;
180
- model?: { provider: string; modelId: string } | null;
181
- error?: string;
182
- };
183
- if (!created.ok || !createdBody.sessionId) {
184
- setState((prev) => ({ ...prev, isGenerating: false, error: createdBody.error ?? `HTTP ${created.status}` }));
185
- return null;
186
- }
187
- const sid = createdBody.sessionId;
188
- connect(sid);
189
- const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
190
- method: 'POST',
191
- headers: { 'Content-Type': 'application/json' },
192
- body: JSON.stringify({
193
- type: 'prompt',
194
- message,
195
- ...(images?.length ? { images } : {}),
196
- }),
197
- });
198
- const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
199
- if (!res.ok || body.error) {
200
- setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
201
- return null;
202
- }
203
- return { sessionId: sid, model: createdBody.model ?? null };
204
- } catch (e) {
205
- setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
206
- return null;
207
- }
208
- }, [connect]);
101
+ // Prompt delivery (warm-up + stream attach + the dispatch-time sidebar
102
+ // signal) lives in its own module so this hook stays under the size ceiling.
103
+ const { sendPrompt, sendNewPrompt } = useOmpPromptSender({ sessionIdRef, connect, setState });
209
104
 
210
105
  /** Abort the running agent turn. */
211
106
  const abort = useCallback(async () => {