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.
- package/README.md +67 -6
- 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.86e2256a70.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.eccad0b70a.js +33 -0
- package/dist/client/static/js/{uy.5f791144ce.js → uy.2e8929ed0c.js} +2 -2
- package/package.json +15 -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 +40 -8
- 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 +23 -12
- package/src/cli/lib/paths.js +8 -28
- package/src/cli/lib/runtime.js +121 -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/chat/omp/index.ts +4 -109
- package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
- package/src/client/hooks/chat/timeline/send.ts +3 -2
- 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 +52 -1
- package/src/server/lib/lifecycle/bun.ts +18 -0
- package/src/server/lib/lifecycle/identity.ts +108 -0
- package/src/server/lib/lifecycle/instance.ts +102 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
- package/src/server/lib/lifecycle/launch-mode.ts +40 -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/lib/lifecycle/restart.test.ts +21 -0
- package/src/server/lib/lifecycle/restart.ts +111 -0
- package/src/server/lib/omp/rpc/session-commands.ts +15 -1
- package/src/server/lib/updates/apply.ts +8 -5
- package/src/server/lib/updates/install.ts +7 -4
- package/src/server/plugins/static.test.ts +63 -0
- package/src/server/plugins/static.ts +28 -4
- package/src/server/routes/health/probe.ts +9 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
- 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/index.0ac93ee878.js +0 -33
- /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.86e2256a70.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.eccad0b70a.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={{
|
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 () => {
|