ompchamber 0.4.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 (94) hide show
  1. package/README.md +56 -7
  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/dc.6dc4b0a04b.js +2 -0
  9. package/dist/client/static/js/async/g.8515cbc6f7.js +2 -0
  10. package/dist/client/static/js/async/hn.b645041e00.js +1 -0
  11. package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
  12. package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
  13. package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
  14. package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
  15. package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
  16. package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
  17. package/dist/client/static/js/async/t.cda4891526.js +1 -0
  18. package/dist/client/static/js/async/tr.e878658140.js +309 -0
  19. package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
  20. package/dist/client/static/js/{index.bf1b1fe988.js → index.47d3b04d5f.js} +7 -7
  21. package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
  22. package/package.json +13 -6
  23. package/src/cli/lib/args.js +7 -6
  24. package/src/cli/lib/commands/logs.js +76 -34
  25. package/src/cli/lib/commands/restart.js +30 -9
  26. package/src/cli/lib/commands/serve.js +19 -4
  27. package/src/cli/lib/commands/status.js +40 -18
  28. package/src/cli/lib/commands/stop.js +12 -7
  29. package/src/cli/lib/commands/update.js +2 -5
  30. package/src/cli/lib/paths.js +8 -28
  31. package/src/cli/lib/runtime.js +118 -108
  32. package/src/client/components/common/code-editor/index.tsx +5 -1
  33. package/src/client/components/common/code-surface/index.tsx +49 -1
  34. package/src/client/components/common/code-surface/measure.ts +99 -0
  35. package/src/client/components/layout/RightActivityBar.tsx +42 -69
  36. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
  37. package/src/client/components/layout/desktop-layout/index.tsx +11 -9
  38. package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
  39. package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
  40. package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
  41. package/src/client/components/mobile/RightSidebar.tsx +42 -119
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
  43. package/src/client/components/workspace/editor/index.tsx +4 -1
  44. package/src/client/components/workspace/file-explorer/TreeItem.tsx +8 -2
  45. package/src/client/components/workspace/file-explorer/index.tsx +6 -0
  46. package/src/client/data/models/catalog.ts +8 -0
  47. package/src/client/data/samples/dialogue-session.ts +1 -1
  48. package/src/client/data/samples/sample-tools/other.ts +1 -1
  49. package/src/client/hooks/chat/timeline/send.ts +1 -1
  50. package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
  51. package/src/client/hooks/editor/use-file-editor.ts +35 -19
  52. package/src/server/index.ts +104 -1
  53. package/src/server/lib/lifecycle/identity.ts +108 -0
  54. package/src/server/lib/lifecycle/instance.ts +102 -0
  55. package/src/server/lib/lifecycle/occupant.ts +101 -0
  56. package/src/server/lib/lifecycle/paths.ts +50 -0
  57. package/src/server/lib/lifecycle/port-guard.ts +170 -0
  58. package/src/server/lib/lifecycle/probe.ts +121 -0
  59. package/src/server/lib/models/provider-registry.server.ts +44 -20
  60. package/src/server/lib/omp/core/cli.ts +3 -2
  61. package/src/server/lib/omp/core/startup.ts +64 -0
  62. package/src/server/lib/omp/rpc/process.ts +1 -1
  63. package/src/server/mock.server.ts +6 -12
  64. package/src/server/plugins/static.test.ts +63 -0
  65. package/src/server/plugins/static.ts +28 -4
  66. package/src/server/routes/chat/stream.ts +13 -30
  67. package/src/server/routes/health/probe.ts +8 -0
  68. package/src/server/routes/models/root.ts +5 -1
  69. package/src/shared/lib/chat/stream-service.ts +0 -76
  70. package/src/shared/lib/fs/paths.ts +19 -0
  71. package/src/shared/lib/markdown/mermaid.ts +8 -0
  72. package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
  73. package/src/shared/lib/workspace/panel-widths.ts +23 -10
  74. package/src/shared/lib/workspace/right-panels.ts +10 -4
  75. package/dist/client/static/js/async/73.50280ce58d.js +0 -1
  76. package/dist/client/static/js/async/a.e05da304c8.js +0 -10
  77. package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
  78. package/dist/client/static/js/async/art.62139ed479.js +0 -2
  79. package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
  80. package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
  81. package/dist/client/static/js/async/l.102a97fce6.js +0 -104
  82. package/dist/client/static/js/async/l6.c56640c205.js +0 -1
  83. package/dist/client/static/js/async/nk.6de011832e.js +0 -1
  84. package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
  85. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  86. package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
  87. /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
  88. /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → dc.6dc4b0a04b.js.LICENSE.txt} +0 -0
  89. /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.8515cbc6f7.js.LICENSE.txt} +0 -0
  90. /package/dist/client/static/js/async/{art.62139ed479.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
  91. /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
  92. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
  93. /package/dist/client/static/js/{index.bf1b1fe988.js.LICENSE.txt → index.47d3b04d5f.js.LICENSE.txt} +0 -0
  94. /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={{
@@ -5,12 +5,16 @@ import { useFetcher } from '@/client/lib/router/fetcher';
5
5
  import { FileIcon } from '@/client/components/common/file-icon';
6
6
  import { FileContextMenu, FileDeleteModal, FileHistoryModal, FileRenameModal } from '@/client/components/workspace/file-explorer/Modals';
7
7
  import { getGitStatusInfo, type FolderGitStatusInfo } from '@/shared/lib/fs/git-status';
8
+ import { toAbsolutePath } from '@/shared/lib/fs/paths';
9
+ import { copyToClipboard } from '@/client/hooks/ui/clipboard';
8
10
  import type { GitChange } from '@/shared/types/git';
9
11
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
10
12
 
11
13
  interface FileTreeItemProps {
12
14
  file: any;
13
15
  rootPath?: string;
16
+ /** Absolute base dir of the listing (server-reported `root`), anchors Copy Path. */
17
+ basePath?: string;
14
18
  repo?: string;
15
19
  onLoadChildren?: (path: string) => Promise<void>;
16
20
  onOpenFile?: (file: any) => void;
@@ -24,6 +28,7 @@ interface FileTreeItemProps {
24
28
  export function FileTreeItem({
25
29
  file,
26
30
  rootPath,
31
+ basePath,
27
32
  repo,
28
33
  onLoadChildren,
29
34
  onOpenFile,
@@ -151,9 +156,9 @@ export function FileTreeItem({
151
156
  fd.append('path', file.path);
152
157
  submitAction(fd);
153
158
  } else if (actionType === 'copy_path') {
154
- navigator.clipboard.writeText('/app/applet/examples/' + file.path);
159
+ void copyToClipboard(toAbsolutePath(basePath, file.path));
155
160
  } else if (actionType === 'copy_relative') {
156
- navigator.clipboard.writeText(file.path);
161
+ void copyToClipboard(file.path);
157
162
  } else if (actionType === 'history') {
158
163
  setShowHistoryModal(true);
159
164
  const fd = new FormData();
@@ -236,6 +241,7 @@ export function FileTreeItem({
236
241
  key={child.id}
237
242
  file={child}
238
243
  rootPath={rootPath}
244
+ basePath={basePath}
239
245
  repo={repo}
240
246
  onLoadChildren={onLoadChildren}
241
247
  onOpenFile={onOpenFile}
@@ -10,6 +10,10 @@ import { usePanelRefresh, useFileMutationRefresh } from '@/client/hooks/workspac
10
10
 
11
11
  export function FileExplorer({ className = '', enabled = true, rootPath, onOpenFile, refreshKey = 0, onRefresh }: { className?: string, enabled?: boolean, rootPath?: string, onOpenFile?: (file: any) => void, refreshKey?: number, onRefresh?: () => void }) {
12
12
  const [tree, setTree] = useState<any[]>([]);
13
+ // Absolute base dir reported by `/api/fs/dir` for the current listing — the
14
+ // workspace root, or the selected nested repo. Copy Path anchors on it
15
+ // because the client's `rootPath` may be rejected/unset.
16
+ const [listingRoot, setListingRoot] = useState('');
13
17
  const [searchQuery, setSearchQuery] = useSessionState<string>('files.searchQuery', '');
14
18
  const [isLoading, setIsLoading] = useState(false);
15
19
  const [storedExpandedPaths, setStoredExpandedPaths, expandedPathsReady] = useSessionState<string[]>('files.expandedPaths', []);
@@ -43,6 +47,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
43
47
  fetch(listUrl())
44
48
  .then(r => r.json())
45
49
  .then(data => {
50
+ if (typeof data.root === 'string') setListingRoot(data.root);
46
51
  if (Array.isArray(data.files)) {
47
52
  setTree(rehydrateTree(data.files, childrenCacheRef.current, expandedPaths));
48
53
  }
@@ -176,6 +181,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
176
181
  key={file.id}
177
182
  file={file}
178
183
  rootPath={rootPath}
184
+ basePath={listingRoot}
179
185
  repo={activeRepo}
180
186
  onLoadChildren={loadChildren}
181
187
  onOpenFile={onOpenFile}
@@ -1,5 +1,13 @@
1
1
  import type { AIModelOption } from '@/shared/types';
2
2
 
3
+ /**
4
+ * MOCK-only demo catalog: the model rows a `MOCK=true` chamber shows before the
5
+ * operator adds any of their own.
6
+ *
7
+ * Real mode never reads this — `/api/models` there is fed by the live omp model
8
+ * registry, and its mutations start from an empty list — so a demo model can
9
+ * never leak into a real installation (see `routes/models/root.ts`).
10
+ */
3
11
  export const INITIAL_MODELS_CATALOG: AIModelOption[] = [
4
12
  // FAVORITES (As seen in image.png)
5
13
  {
@@ -277,7 +277,7 @@ export function getSampleDialogueSession(): {
277
277
  id: '65e71033',
278
278
  role: 'ai',
279
279
  content:
280
- 'Repo summary from `RepoSummarizer`:\n\n**OMPChamber** — developer web-view console and diagnostic chamber for AI **Oh-My-Pi** autonomous agent.\n\n**Stack:** Remix v2 + React 19 + Vite, Tailwind CSS v4, TypeScript (strict, ES2022). Runs under npm or Bun.\n\n**Structure:**\n- `app/routes/` — Remix flat-routes, large REST-style `api/` domain tree\n- `app/components/` — common, layout, workspace, mobile, settings\n- `app/hooks/` — chat, terminal, git-tree, agent streaming\n- `app/lib/omp/` — bridges to oh-my-pi runtime (RPC managers, session readers)\n- `app/types/`, `app/data/` (mock presets)\n\n**Config:** `MOCK` mode toggles seeded demo data vs real data. SQLite persistence (`app/db.server.ts`).\n\n**Deployment-relevant:** PWA support, `@google/genai` assistant, xterm/KaTeX/Shiki rendering.',
280
+ 'Repo summary from `RepoSummarizer`:\n\n**OMPChamber** — developer web-view console and diagnostic chamber for AI **Oh-My-Pi** autonomous agent.\n\n**Stack:** Remix v2 + React 19 + Vite, Tailwind CSS v4, TypeScript (strict, ES2022). Runs under npm or Bun.\n\n**Structure:**\n- `app/routes/` — Remix flat-routes, large REST-style `api/` domain tree\n- `app/components/` — common, layout, workspace, mobile, settings\n- `app/hooks/` — chat, terminal, git-tree, agent streaming\n- `app/lib/omp/` — bridges to oh-my-pi runtime (RPC managers, session readers)\n- `app/types/`, `app/data/` (mock presets)\n\n**Config:** `MOCK` mode toggles seeded demo data vs real data. SQLite persistence (`app/db.server.ts`).\n\n**Deployment-relevant:** PWA support, xterm/KaTeX/Shiki rendering.',
281
281
  date: 'Sep 9, 04:44 AM',
282
282
  },
283
283
  ],
@@ -88,7 +88,7 @@ export const otherTools: ToolCallData[] = [
88
88
  target: 'app/mock.server.ts',
89
89
  output: `1: export function isMockMode(): boolean {
90
90
  2: const envVal = (Bun.env.MOCK || "").trim().toLowerCase();
91
- 3: return envVal !== "false" && envVal !== "0";
91
+ 3: return envVal === "true" || envVal === "1" || envVal === "on" || envVal === "yes";
92
92
  4: }`,
93
93
  status: 'success',
94
94
  duration: '14ms',
@@ -311,7 +311,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
311
311
  }
312
312
  }
313
313
 
314
- // Mock / chamber-created session: existing Gemini/simulated SSE path.
314
+ // Mock / chamber-created session: existing simulated SSE path.
315
315
  // Cancel any previous stream
316
316
  optimisticUserIdRef.current = null;
317
317
  if (abortControllerRef.current) {