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.
- package/README.md +46 -1
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/{index.5291f58223.css → index.51f68210a5.css} +1 -1
- package/dist/client/static/js/async/{3y.ededeebde8.js → 3y.4c2596f64c.js} +3 -3
- package/dist/client/static/js/async/73.308dff4da8.js +1 -0
- package/dist/client/static/js/async/{ty.3d93cf5f5f.js → 7g.a7814350c3.js} +1 -1
- package/dist/client/static/js/async/a.bce4542392.js +1 -0
- package/dist/client/static/js/async/g.8515cbc6f7.js +2 -0
- package/dist/client/static/js/async/hn.b645041e00.js +1 -0
- package/dist/client/static/js/async/{jg.69a4beade8.js → jg.d86ebd4013.js} +1 -1
- package/dist/client/static/js/async/l.12dd34e1be.js +1 -0
- package/dist/client/static/js/async/ls.9d9a01026c.js +104 -0
- package/dist/client/static/js/async/mw.ca1512563c.js +10 -0
- package/dist/client/static/js/async/nk.fc7e58325e.js +1 -0
- package/dist/client/static/js/async/{q.7fb4d2f374.js → q.542c184fec.js} +3 -3
- package/dist/client/static/js/async/t.cda4891526.js +1 -0
- package/dist/client/static/js/async/tr.e878658140.js +309 -0
- package/dist/client/static/js/async/uf.24ca3d49c3.js +2 -0
- package/dist/client/static/js/{index.0ac93ee878.js → index.47d3b04d5f.js} +7 -7
- package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
- package/package.json +13 -5
- package/src/cli/lib/args.js +6 -6
- package/src/cli/lib/commands/logs.js +76 -34
- package/src/cli/lib/commands/restart.js +30 -9
- package/src/cli/lib/commands/serve.js +12 -4
- package/src/cli/lib/commands/status.js +40 -18
- package/src/cli/lib/commands/stop.js +12 -7
- package/src/cli/lib/commands/update.js +2 -5
- package/src/cli/lib/paths.js +8 -28
- package/src/cli/lib/runtime.js +118 -108
- package/src/client/components/common/code-editor/index.tsx +5 -1
- package/src/client/components/common/code-surface/index.tsx +49 -1
- package/src/client/components/common/code-surface/measure.ts +99 -0
- package/src/client/components/layout/RightActivityBar.tsx +42 -69
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +11 -9
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +22 -29
- package/src/client/components/layout/desktop-layout/resizer/utils.test.ts +33 -0
- package/src/client/components/layout/desktop-layout/resizer/utils.ts +34 -0
- package/src/client/components/mobile/RightSidebar.tsx +42 -119
- package/src/client/components/mobile/mobile-main-view/Header.tsx +17 -1
- package/src/client/components/workspace/editor/index.tsx +4 -1
- package/src/client/hooks/editor/use-file-editor.test.ts +158 -0
- package/src/client/hooks/editor/use-file-editor.ts +35 -19
- package/src/server/index.ts +51 -1
- package/src/server/lib/lifecycle/identity.ts +108 -0
- package/src/server/lib/lifecycle/instance.ts +102 -0
- package/src/server/lib/lifecycle/occupant.ts +101 -0
- package/src/server/lib/lifecycle/paths.ts +50 -0
- package/src/server/lib/lifecycle/port-guard.ts +170 -0
- package/src/server/lib/lifecycle/probe.ts +121 -0
- package/src/server/plugins/static.test.ts +63 -0
- package/src/server/plugins/static.ts +28 -4
- package/src/server/routes/health/probe.ts +8 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +5 -5
- package/src/shared/lib/workspace/panel-widths.ts +23 -10
- package/src/shared/lib/workspace/right-panels.ts +10 -4
- package/dist/client/static/js/async/73.50280ce58d.js +0 -1
- package/dist/client/static/js/async/a.e05da304c8.js +0 -10
- package/dist/client/static/js/async/ar.1e14da5588.js +0 -1
- package/dist/client/static/js/async/g.7a4bbd4798.js +0 -2
- package/dist/client/static/js/async/hn.7568bdb58b.js +0 -1
- package/dist/client/static/js/async/l.102a97fce6.js +0 -104
- package/dist/client/static/js/async/l6.c56640c205.js +0 -1
- package/dist/client/static/js/async/nk.6de011832e.js +0 -1
- package/dist/client/static/js/async/t.cb3fcf8615.js +0 -1
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vn.e98e2f3b61.js +0 -309
- /package/dist/client/static/js/async/{3y.ededeebde8.js.LICENSE.txt → 3y.4c2596f64c.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.7a4bbd4798.js.LICENSE.txt → g.8515cbc6f7.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{a.e05da304c8.js.LICENSE.txt → mw.ca1512563c.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.7fb4d2f374.js.LICENSE.txt → q.542c184fec.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → uf.24ca3d49c3.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0ac93ee878.js.LICENSE.txt → index.47d3b04d5f.js.LICENSE.txt} +0 -0
- /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
|
-
() => (
|
|
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=
|
|
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={
|
|
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.
|
|
103
|
-
//
|
|
104
|
-
//
|
|
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
|
|
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={
|
|
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
|
|
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
|
-
? {
|
|
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
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|