ompchamber 1.0.0 → 1.1.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/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b859cc1cc5.css +2 -0
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
- package/dist/client/static/js/index.6e065017eb.js +33 -0
- package/package.json +1 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
- package/src/client/components/workspace/chat-timeline/index.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/hooks/chat/composer/index.ts +28 -8
- package/src/client/hooks/chat/timeline/actions.ts +24 -3
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +13 -1
- package/src/client/hooks/chat/timeline/send.ts +16 -5
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/queue/delivery.server.test.ts +203 -0
- package/src/server/lib/queue/delivery.server.ts +38 -8
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/shared/lib/chat/composer/client.ts +17 -8
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +157 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +109 -20
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +3 -0
- package/src/shared/types/chat.ts +38 -65
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/dist/client/static/css/index.e880517fcd.css +0 -2
- package/dist/client/static/js/index.0de15c5e17.js +0 -33
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'preact/compat';
|
|
2
2
|
import { useRef as useRefBase } from 'preact/hooks';
|
|
3
3
|
import type { ComponentChildren, RefObject } from 'preact';
|
|
4
|
-
import type { CSSProperties
|
|
5
|
-
import { clamp, toPx
|
|
4
|
+
import type { CSSProperties } from 'preact';
|
|
5
|
+
import { clamp, toPx } from '@/client/components/layout/desktop-layout/resizer/utils';
|
|
6
|
+
import { useSeparatorDrag } from '@/client/components/layout/desktop-layout/resizer/separator-drag';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Hand-rolled resizable panel group (the Preact-native replacement for
|
|
@@ -15,6 +16,10 @@ import { clamp, toPx, transfer } from '@/client/components/layout/desktop-layout
|
|
|
15
16
|
* clamped by both neighbors' min/max constraints, and commits once per drag
|
|
16
17
|
* through `onLayoutChanged(_, { isUserInteraction: true })`.
|
|
17
18
|
*
|
|
19
|
+
* Sizes are pixels here. A caller that stores a width as a share of the group's
|
|
20
|
+
* area (see `panel-widths.ts`) resolves it to px before passing it in — the
|
|
21
|
+
* resizer has no percentage path and should not grow one.
|
|
22
|
+
*
|
|
18
23
|
* The filler's `minSize` is a real CSS floor, which is what makes a shrinking
|
|
19
24
|
* window push the resizable panels toward their own floors instead of
|
|
20
25
|
* collapsing the filler to nothing (a zero flex basis absorbs no negative free
|
|
@@ -39,14 +44,9 @@ export interface PanelImperativeHandle {
|
|
|
39
44
|
getSize: () => { inPixels: number } | null;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
export interface GroupImperativeHandle {
|
|
43
|
-
/** Batch resize of the named panels, in pixels. */
|
|
44
|
-
setLayout: (layout: Record<string, number>) => void;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
47
|
export type LayoutChangedHandler = (layout: Record<string, number>, meta: LayoutMeta) => void;
|
|
48
48
|
|
|
49
|
-
interface PanelState {
|
|
49
|
+
export interface PanelState {
|
|
50
50
|
id: string;
|
|
51
51
|
isFiller: boolean;
|
|
52
52
|
minSize: number;
|
|
@@ -69,7 +69,8 @@ const GroupContext = createContext<GroupContextValue | null>(null);
|
|
|
69
69
|
export function Group(props: {
|
|
70
70
|
orientation: GroupOrientation;
|
|
71
71
|
id?: string;
|
|
72
|
-
|
|
72
|
+
/** The group's own element, for measuring the area its panels share. */
|
|
73
|
+
groupRef?: RefObject<HTMLDivElement>;
|
|
73
74
|
onLayoutChanged?: LayoutChangedHandler;
|
|
74
75
|
className?: string;
|
|
75
76
|
children: ComponentChildren;
|
|
@@ -86,35 +87,10 @@ export function Group(props: {
|
|
|
86
87
|
[orientation],
|
|
87
88
|
);
|
|
88
89
|
|
|
89
|
-
const snapshot = () => {
|
|
90
|
-
const layout: Record<string, number> = {};
|
|
91
|
-
for (const [pid, state] of panelsRef.current) {
|
|
92
|
-
const px = state.getSize();
|
|
93
|
-
if (px != null) layout[pid] = px;
|
|
94
|
-
}
|
|
95
|
-
return layout;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
useEffect(() => {
|
|
99
|
-
if (!groupRef) return;
|
|
100
|
-
groupRef.current = {
|
|
101
|
-
setLayout: (layout) => {
|
|
102
|
-
for (const [pid, px] of Object.entries(layout)) {
|
|
103
|
-
const state = panelsRef.current.get(pid);
|
|
104
|
-
if (!state || state.isFiller || typeof px !== 'number') continue;
|
|
105
|
-
state.setSize(clamp(px, state.minSize, state.maxSize));
|
|
106
|
-
}
|
|
107
|
-
changedRef.current?.(snapshot(), { isUserInteraction: false });
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
return () => {
|
|
111
|
-
groupRef.current = null;
|
|
112
|
-
};
|
|
113
|
-
}, [groupRef]);
|
|
114
|
-
|
|
115
90
|
return (
|
|
116
91
|
<GroupContext.Provider value={ctx}>
|
|
117
92
|
<div
|
|
93
|
+
ref={groupRef}
|
|
118
94
|
data-panel-group=""
|
|
119
95
|
data-panel-group-id={id}
|
|
120
96
|
className={className}
|
|
@@ -133,14 +109,19 @@ export function Panel(props: {
|
|
|
133
109
|
defaultSize?: number | string;
|
|
134
110
|
minSize?: number | string;
|
|
135
111
|
maxSize?: number | string;
|
|
136
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Render at zero width without unmounting. The panel keeps its size state, so
|
|
114
|
+
* reopening restores the remembered width and the width transition animates
|
|
115
|
+
* the movement instead of the content popping in.
|
|
116
|
+
*/
|
|
117
|
+
collapsed?: boolean;
|
|
137
118
|
/** This panel absorbs the remaining space instead of holding its own px. */
|
|
138
119
|
filler?: boolean;
|
|
139
120
|
className?: string;
|
|
140
121
|
style?: CSSProperties;
|
|
141
122
|
children: ComponentChildren;
|
|
142
123
|
}) {
|
|
143
|
-
const { id, panelRef, defaultSize, minSize = 0, maxSize, filler = false, className, style, children } = props;
|
|
124
|
+
const { id, panelRef, defaultSize, minSize = 0, maxSize, collapsed = false, filler = false, className, style, children } = props;
|
|
144
125
|
const group = useContext(GroupContext);
|
|
145
126
|
if (!group) {
|
|
146
127
|
throw new Error(`<Panel id="${id}"> must be rendered inside a <Group>`);
|
|
@@ -184,6 +165,21 @@ export function Panel(props: {
|
|
|
184
165
|
}
|
|
185
166
|
});
|
|
186
167
|
|
|
168
|
+
// Adopt a changed `defaultSize`. This is what restores a panel after its
|
|
169
|
+
// owner swaps it — a right-panel view switch, an editor source↔diff switch —
|
|
170
|
+
// and after a resize recomputes it, without an imperative push from the
|
|
171
|
+
// owner. A drag does not fight it: the owner only produces a new
|
|
172
|
+
// `defaultSize` once the drag commits, and that value is the size the drag
|
|
173
|
+
// already applied.
|
|
174
|
+
const lastDefaultPxRef = useRef<number | null>(null);
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (filler || defaultSize == null) return;
|
|
177
|
+
const nextPx = toPx(defaultSize, minPx);
|
|
178
|
+
if (lastDefaultPxRef.current === nextPx) return;
|
|
179
|
+
lastDefaultPxRef.current = nextPx;
|
|
180
|
+
setSizePx(clamp(nextPx, minPx, maxPx));
|
|
181
|
+
}, [defaultSize, filler, minPx, maxPx]);
|
|
182
|
+
|
|
187
183
|
useEffect(() => {
|
|
188
184
|
if (!panelRef) return;
|
|
189
185
|
panelRef.current = {
|
|
@@ -212,17 +208,29 @@ export function Panel(props: {
|
|
|
212
208
|
}
|
|
213
209
|
: {
|
|
214
210
|
flex: '0 1 auto',
|
|
215
|
-
width: orientation === 'horizontal' ? sizePx ?? undefined : undefined,
|
|
216
|
-
height: orientation === 'horizontal' ? undefined : sizePx ?? undefined,
|
|
217
|
-
minWidth: orientation === 'horizontal' ? minPx : undefined,
|
|
218
|
-
minHeight: orientation === 'horizontal' ? undefined : minPx,
|
|
211
|
+
width: orientation === 'horizontal' ? (collapsed ? 0 : sizePx ?? undefined) : undefined,
|
|
212
|
+
height: orientation === 'horizontal' ? undefined : collapsed ? 0 : sizePx ?? undefined,
|
|
213
|
+
minWidth: orientation === 'horizontal' ? (collapsed ? 0 : minPx) : undefined,
|
|
214
|
+
minHeight: orientation === 'horizontal' ? undefined : collapsed ? 0 : minPx,
|
|
219
215
|
maxWidth: maxSize != null ? maxPx : undefined,
|
|
216
|
+
// A drag re-applies the real width at most every RESIZE_FOLLOW_INTERVAL_MS
|
|
217
|
+
// (see useSeparatorDrag), and this transition is what makes those steps
|
|
218
|
+
// read as one movement. It also carries a panel to its remembered width
|
|
219
|
+
// on a view switch, and to zero on collapse, instead of snapping.
|
|
220
|
+
transition: `${orientation === 'horizontal' ? 'width' : 'height'} 200ms cubic-bezier(0.22, 1, 0.36, 1)`,
|
|
220
221
|
overflow: 'hidden',
|
|
221
222
|
position: 'relative',
|
|
222
223
|
};
|
|
223
224
|
|
|
224
225
|
return (
|
|
225
|
-
<div
|
|
226
|
+
<div
|
|
227
|
+
ref={elementRef}
|
|
228
|
+
data-panel=""
|
|
229
|
+
data-panel-id={id}
|
|
230
|
+
data-panel-collapsed={collapsed ? '' : undefined}
|
|
231
|
+
className={className}
|
|
232
|
+
style={{ ...baseStyle, ...style }}
|
|
233
|
+
>
|
|
226
234
|
{children}
|
|
227
235
|
</div>
|
|
228
236
|
);
|
|
@@ -240,88 +248,26 @@ export function Panel(props: {
|
|
|
240
248
|
export function Separator(props: { className?: string; style?: CSSProperties; children?: ComponentChildren }) {
|
|
241
249
|
const { className, style, children } = props;
|
|
242
250
|
const group = useContext(GroupContext);
|
|
243
|
-
const [dragging, setDragging] = useState(false);
|
|
244
251
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
245
|
-
const dragCleanupRef = useRef<(() => void) | null>(null);
|
|
246
|
-
|
|
247
|
-
// A drag owns two window listeners; if the separator unmounts mid-drag (a
|
|
248
|
-
// layout switch) nothing else would ever remove them.
|
|
249
|
-
useEffect(() => () => dragCleanupRef.current?.(), []);
|
|
250
252
|
|
|
251
253
|
if (!group) {
|
|
252
254
|
throw new Error('<Separator> must be rendered inside a <Group>');
|
|
253
255
|
}
|
|
254
256
|
const { orientation, panels, onLayoutChanged } = group;
|
|
255
|
-
|
|
256
|
-
const handlePointerDown = (e: TargetedPointerEvent<HTMLDivElement>) => {
|
|
257
|
-
const host = hostRef.current;
|
|
258
|
-
if (!host || e.button !== 0) return;
|
|
259
|
-
const previous = host.previousElementSibling as HTMLElement | null;
|
|
260
|
-
const next = host.nextElementSibling as HTMLElement | null;
|
|
261
|
-
const aId = previous?.getAttribute('data-panel-id');
|
|
262
|
-
const bId = next?.getAttribute('data-panel-id');
|
|
263
|
-
const a = aId ? panels.current.get(aId) : undefined;
|
|
264
|
-
const b = bId ? panels.current.get(bId) : undefined;
|
|
265
|
-
if (!a || !b || !previous || !next) return;
|
|
266
|
-
|
|
267
|
-
e.preventDefault();
|
|
268
|
-
setDragging(true);
|
|
269
|
-
|
|
270
|
-
const startPos = orientation === 'horizontal' ? e.clientX : e.clientY;
|
|
271
|
-
const measure = (el: HTMLElement) =>
|
|
272
|
-
orientation === 'horizontal' ? el.getBoundingClientRect().width : el.getBoundingClientRect().height;
|
|
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);
|
|
278
|
-
|
|
279
|
-
const onMove = (ev: PointerEvent) => {
|
|
280
|
-
const pos = orientation === 'horizontal' ? ev.clientX : ev.clientY;
|
|
281
|
-
const delta = transfer(
|
|
282
|
-
{ size: aStart, min: a.minSize, max: a.maxSize },
|
|
283
|
-
{ size: bStart, min: b.minSize, max: b.maxSize },
|
|
284
|
-
pos - startPos,
|
|
285
|
-
);
|
|
286
|
-
a.setSize(clamp(aStart + delta, a.minSize, a.maxSize));
|
|
287
|
-
if (!b.isFiller) b.setSize(clamp(bStart - delta, b.minSize, b.maxSize));
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
const cleanup = () => {
|
|
291
|
-
window.removeEventListener('pointermove', onMove);
|
|
292
|
-
window.removeEventListener('pointerup', onUp);
|
|
293
|
-
window.removeEventListener('pointercancel', onUp);
|
|
294
|
-
dragCleanupRef.current = null;
|
|
295
|
-
setDragging(false);
|
|
296
|
-
const layout: Record<string, number> = {};
|
|
297
|
-
for (const [pid, state] of panels.current) {
|
|
298
|
-
const px = state.getSize();
|
|
299
|
-
if (px != null) layout[pid] = px;
|
|
300
|
-
}
|
|
301
|
-
onLayoutChanged.current?.(layout, { isUserInteraction: true });
|
|
302
|
-
};
|
|
303
|
-
const onUp = () => cleanup();
|
|
304
|
-
|
|
305
|
-
window.addEventListener('pointermove', onMove);
|
|
306
|
-
window.addEventListener('pointerup', onUp);
|
|
307
|
-
window.addEventListener('pointercancel', onUp);
|
|
308
|
-
dragCleanupRef.current = cleanup;
|
|
309
|
-
};
|
|
257
|
+
const { dragging, guidePx, onPointerDown } = useSeparatorDrag(hostRef, orientation, panels, onLayoutChanged);
|
|
310
258
|
|
|
311
259
|
return (
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
{children}
|
|
324
|
-
</div>
|
|
260
|
+
<div
|
|
261
|
+
ref={hostRef}
|
|
262
|
+
data-separator=""
|
|
263
|
+
role="separator"
|
|
264
|
+
aria-orientation={orientation === 'horizontal' ? 'vertical' : 'horizontal'}
|
|
265
|
+
className={className}
|
|
266
|
+
style={style}
|
|
267
|
+
onPointerDown={onPointerDown}
|
|
268
|
+
onDragStart={(e: DragEvent) => e.preventDefault()}
|
|
269
|
+
>
|
|
270
|
+
{children}
|
|
325
271
|
{dragging && (
|
|
326
272
|
<div
|
|
327
273
|
style={{
|
|
@@ -332,6 +278,21 @@ export function Separator(props: { className?: string; style?: CSSProperties; ch
|
|
|
332
278
|
} as CSSProperties}
|
|
333
279
|
/>
|
|
334
280
|
)}
|
|
335
|
-
|
|
281
|
+
{guidePx !== null && (
|
|
282
|
+
<div
|
|
283
|
+
aria-hidden="true"
|
|
284
|
+
style={{
|
|
285
|
+
position: 'fixed',
|
|
286
|
+
...(orientation === 'horizontal'
|
|
287
|
+
? { left: `${guidePx}px`, top: 0, bottom: 0, width: '1px' }
|
|
288
|
+
: { top: `${guidePx}px`, left: 0, right: 0, height: '1px' }),
|
|
289
|
+
zIndex: 9998,
|
|
290
|
+
pointerEvents: 'none',
|
|
291
|
+
background: 'var(--theme-ink)',
|
|
292
|
+
opacity: 0.35,
|
|
293
|
+
} as CSSProperties}
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
336
297
|
);
|
|
337
|
-
}
|
|
298
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
+
import type { RefObject } from 'preact';
|
|
3
|
+
import type { TargetedPointerEvent } from 'preact';
|
|
4
|
+
import { clamp, transfer } from '@/client/components/layout/desktop-layout/resizer/utils';
|
|
5
|
+
import { RESIZE_FOLLOW_INTERVAL_MS } from '@/shared/lib/workspace/panel-widths';
|
|
6
|
+
import type { GroupOrientation, LayoutChangedHandler, PanelState } from '@/client/components/layout/desktop-layout/resizer';
|
|
7
|
+
|
|
8
|
+
export interface SeparatorDrag {
|
|
9
|
+
/** A drag is in flight; the caller renders the pointer-capture blocker. */
|
|
10
|
+
dragging: boolean;
|
|
11
|
+
/** Pointer position of the guide line, or null when not dragging. */
|
|
12
|
+
guidePx: number | null;
|
|
13
|
+
onPointerDown: (event: TargetedPointerEvent<HTMLDivElement>) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The deferred drag behind a separator.
|
|
18
|
+
*
|
|
19
|
+
* A ghost guide line follows the pointer, and the real width is re-applied at
|
|
20
|
+
* most every `RESIZE_FOLLOW_INTERVAL_MS`. Applying it on every pointermove
|
|
21
|
+
* re-renders both panels per frame and reflows whatever they contain — an xterm
|
|
22
|
+
* buffer, a CodeMirror document, a browser iframe — for a width the user is
|
|
23
|
+
* still choosing. The panel's own width transition smooths the steps that land.
|
|
24
|
+
*
|
|
25
|
+
* Kept out of `index.tsx` so the components stay inside the repo's file-size
|
|
26
|
+
* ceiling.
|
|
27
|
+
*/
|
|
28
|
+
export function useSeparatorDrag(
|
|
29
|
+
hostRef: RefObject<HTMLDivElement | null>,
|
|
30
|
+
orientation: GroupOrientation,
|
|
31
|
+
panels: { current: Map<string, PanelState> },
|
|
32
|
+
onLayoutChanged: { current: LayoutChangedHandler | null },
|
|
33
|
+
): SeparatorDrag {
|
|
34
|
+
const [dragging, setDragging] = useState(false);
|
|
35
|
+
// State, not a ref: the guide line has to move with the pointer even though
|
|
36
|
+
// the panels deliberately do not.
|
|
37
|
+
const [guidePx, setGuidePx] = useState<number | null>(null);
|
|
38
|
+
const cleanupRef = useRef<(() => void) | null>(null);
|
|
39
|
+
|
|
40
|
+
// A drag owns two window listeners; if the separator unmounts mid-drag (a
|
|
41
|
+
// layout switch) nothing else would ever remove them.
|
|
42
|
+
useEffect(() => () => cleanupRef.current?.(), []);
|
|
43
|
+
|
|
44
|
+
const onPointerDown = (e: TargetedPointerEvent<HTMLDivElement>) => {
|
|
45
|
+
const host = hostRef.current;
|
|
46
|
+
if (!host || e.button !== 0) return;
|
|
47
|
+
// Walk past separators and collapsed panels to the real neighbours. A
|
|
48
|
+
// collapsed panel keeps its DOM node (so its state survives and the width
|
|
49
|
+
// transition has something to animate), but it is zero-width and its own
|
|
50
|
+
// floor still reads as 320 in the registry — pairing a drag against it
|
|
51
|
+
// would clamp every delta to zero and kill the separator.
|
|
52
|
+
const neighbour = (start: Element | null, step: 'previous' | 'next') => {
|
|
53
|
+
let node = start;
|
|
54
|
+
while (node) {
|
|
55
|
+
if (node.hasAttribute('data-separator') || node.hasAttribute('data-panel-collapsed')) {
|
|
56
|
+
node = step === 'previous' ? node.previousElementSibling : node.nextElementSibling;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
return node instanceof HTMLElement && node.hasAttribute('data-panel') ? node : null;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const previous = neighbour(host.previousElementSibling, 'previous');
|
|
65
|
+
const next = neighbour(host.nextElementSibling, 'next');
|
|
66
|
+
const a = previous ? panels.current.get(previous.getAttribute('data-panel-id') ?? '') : undefined;
|
|
67
|
+
const b = next ? panels.current.get(next.getAttribute('data-panel-id') ?? '') : undefined;
|
|
68
|
+
if (!a || !b || !previous || !next) return;
|
|
69
|
+
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
setDragging(true);
|
|
72
|
+
|
|
73
|
+
const horizontal = orientation === 'horizontal';
|
|
74
|
+
const startPos = horizontal ? e.clientX : e.clientY;
|
|
75
|
+
const measure = (el: HTMLElement) =>
|
|
76
|
+
horizontal ? el.getBoundingClientRect().width : el.getBoundingClientRect().height;
|
|
77
|
+
// Measure, never ask the panel: a window narrow enough for flexbox to
|
|
78
|
+
// shrink a panel below its specified width would otherwise start the drag
|
|
79
|
+
// from a size the panel does not have on screen.
|
|
80
|
+
const aStart = measure(previous);
|
|
81
|
+
const bStart = measure(next);
|
|
82
|
+
|
|
83
|
+
// The size the throttle last committed, kept apart from the guide line so
|
|
84
|
+
// every step that lands is a legal clamped size.
|
|
85
|
+
let pending: { a: number; b: number } | null = null;
|
|
86
|
+
let followTimer: ReturnType<typeof setTimeout> | undefined;
|
|
87
|
+
|
|
88
|
+
const apply = (sizes: { a: number; b: number }) => {
|
|
89
|
+
a.setSize(sizes.a);
|
|
90
|
+
if (!b.isFiller) b.setSize(sizes.b);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const onMove = (ev: PointerEvent) => {
|
|
94
|
+
const pos = horizontal ? ev.clientX : ev.clientY;
|
|
95
|
+
const delta = transfer(
|
|
96
|
+
{ size: aStart, min: a.minSize, max: a.maxSize },
|
|
97
|
+
{ size: bStart, min: b.minSize, max: b.maxSize },
|
|
98
|
+
pos - startPos,
|
|
99
|
+
);
|
|
100
|
+
pending = {
|
|
101
|
+
a: clamp(aStart + delta, a.minSize, a.maxSize),
|
|
102
|
+
b: clamp(bStart - delta, b.minSize, b.maxSize),
|
|
103
|
+
};
|
|
104
|
+
setGuidePx(pos);
|
|
105
|
+
if (followTimer === undefined) {
|
|
106
|
+
followTimer = setTimeout(() => {
|
|
107
|
+
followTimer = undefined;
|
|
108
|
+
if (pending) apply(pending);
|
|
109
|
+
}, RESIZE_FOLLOW_INTERVAL_MS);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const onUp = () => {
|
|
114
|
+
window.removeEventListener('pointermove', onMove);
|
|
115
|
+
window.removeEventListener('pointerup', onUp);
|
|
116
|
+
window.removeEventListener('pointercancel', onUp);
|
|
117
|
+
clearTimeout(followTimer);
|
|
118
|
+
cleanupRef.current = null;
|
|
119
|
+
setDragging(false);
|
|
120
|
+
setGuidePx(null);
|
|
121
|
+
// Land the exact final size once, so the release position is the one the
|
|
122
|
+
// guide line showed rather than the last throttled step.
|
|
123
|
+
if (pending) apply(pending);
|
|
124
|
+
|
|
125
|
+
const layout: Record<string, number> = {};
|
|
126
|
+
for (const [pid, state] of panels.current) {
|
|
127
|
+
const px = state.getSize();
|
|
128
|
+
if (px != null) layout[pid] = px;
|
|
129
|
+
}
|
|
130
|
+
onLayoutChanged.current?.(layout, { isUserInteraction: true });
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
window.addEventListener('pointermove', onMove);
|
|
134
|
+
window.addEventListener('pointerup', onUp);
|
|
135
|
+
window.addEventListener('pointercancel', onUp);
|
|
136
|
+
cleanupRef.current = onUp;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return { dragging, guidePx, onPointerDown };
|
|
140
|
+
}
|
|
@@ -5,7 +5,7 @@ export function SessionSidebarHeader({ onNewSession }: { onNewSession: () => voi
|
|
|
5
5
|
<>
|
|
6
6
|
{/* App Title */}
|
|
7
7
|
<div
|
|
8
|
-
className="h-
|
|
8
|
+
className="h-14 flex-shrink-0 flex items-center px-4 border-b border-ink/10 titlebar-drag-region select-none"
|
|
9
9
|
style={{ paddingLeft: 'max(1rem, env(titlebar-area-x, 0px))' }}
|
|
10
10
|
>
|
|
11
11
|
<span className="font-bold text-sm tracking-tight flex items-center">
|
|
@@ -33,6 +33,15 @@ export function SessionItem({
|
|
|
33
33
|
onToggleExpand,
|
|
34
34
|
}: SessionItemProps) {
|
|
35
35
|
const showChevron = Boolean(expandable && hasSubagents && onToggleExpand);
|
|
36
|
+
// The slot shows one glyph, never two side by side. A live run signal owns
|
|
37
|
+
// it at rest — the spinner, or the blocked-on-a-question cue, which says it
|
|
38
|
+
// is YOUR turn. With nothing live to report the chevron is PINNED, collapsed
|
|
39
|
+
// or expanded: it is the only affordance left worth showing, and making it
|
|
40
|
+
// hover-only would leave the roster reachable only by hovering exactly the
|
|
41
|
+
// right 16px.
|
|
42
|
+
const liveRun = awaitingInput || status === 'stream';
|
|
43
|
+
const chevronPinned = showChevron && !liveRun;
|
|
44
|
+
const showStatus = Boolean(awaitingInput || status) && !chevronPinned;
|
|
36
45
|
const {
|
|
37
46
|
isEditing,
|
|
38
47
|
draft,
|
|
@@ -52,11 +61,16 @@ export function SessionItem({
|
|
|
52
61
|
: 'text-ink/75 hover:text-ink hover:bg-ink/5'
|
|
53
62
|
}`}
|
|
54
63
|
>
|
|
55
|
-
{/* One 16px slot owns both icons
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
{/* One 16px slot owns both icons — never two side by side. A live run
|
|
65
|
+
signal (blocked question, run spinner) holds the slot; otherwise the
|
|
66
|
+
chevron is pinned, collapsed or expanded. While the signal holds it,
|
|
67
|
+
hovering hands the slot to the chevron so the roster stays reachable
|
|
68
|
+
mid-run.
|
|
69
|
+
Both layers are pointer-inert for the opposite state: the toggle
|
|
70
|
+
while hidden, the status glyph ALWAYS (it is painted after the
|
|
71
|
+
toggle, so without that it wins the hit test over the revealed
|
|
72
|
+
chevron and every click on a badged row selects the session instead
|
|
73
|
+
of expanding it). */}
|
|
60
74
|
<span className="relative w-4 h-4 shrink-0">
|
|
61
75
|
{showChevron && onToggleExpand && (
|
|
62
76
|
<button
|
|
@@ -67,18 +81,18 @@ export function SessionItem({
|
|
|
67
81
|
}}
|
|
68
82
|
title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
|
|
69
83
|
aria-expanded={isExpanded}
|
|
70
|
-
className={`peer absolute inset-0 flex items-center justify-center rounded cursor-pointer
|
|
71
|
-
|
|
72
|
-
? ''
|
|
73
|
-
: 'pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:opacity-100'
|
|
84
|
+
className={`peer absolute inset-0 flex items-center justify-center rounded cursor-pointer transition-opacity ${
|
|
85
|
+
chevronPinned
|
|
86
|
+
? 'text-ink/60 hover:text-ink'
|
|
87
|
+
: 'text-ink/40 hover:text-ink pointer-events-none opacity-0 group-hover/item:pointer-events-auto group-hover/item:opacity-100 focus-visible:opacity-100'
|
|
74
88
|
}`}
|
|
75
89
|
>
|
|
76
|
-
{isExpanded ? <ChevronDown size={13}
|
|
90
|
+
{isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
|
77
91
|
</button>
|
|
78
92
|
)}
|
|
79
|
-
{
|
|
93
|
+
{showStatus && (
|
|
80
94
|
<span
|
|
81
|
-
className={`absolute inset-0 flex items-center justify-center text-ink/60 transition-opacity ${
|
|
95
|
+
className={`pointer-events-none absolute inset-0 flex items-center justify-center text-ink/60 transition-opacity ${
|
|
82
96
|
showChevron ? 'group-hover/item:opacity-0 peer-focus-visible:opacity-0' : ''
|
|
83
97
|
}`}
|
|
84
98
|
>
|
|
@@ -3,6 +3,7 @@ import { useSearchParams } from '@/client/lib/router/search-params';
|
|
|
3
3
|
import { SubagentStatusIcon } from '@/client/components/common/SubagentStatusIcon';
|
|
4
4
|
import { isRecord } from '@/shared/lib/util/guards';
|
|
5
5
|
import { fetchSubagentHistory, historyEntryToSubagentInfo } from '@/shared/lib/omp/subagent/history/client';
|
|
6
|
+
import { subagentRowLabel } from '@/shared/lib/omp/subagent/label';
|
|
6
7
|
import { mergeSubagentRoster, parseSubagentLifecycle, parseSubagentProgress, parseSubagentRosterResponse } from '@/shared/lib/omp/subagent/parse';
|
|
7
8
|
import type { SubagentInfo, SubagentProgress } from '@/shared/types';
|
|
8
9
|
|
|
@@ -123,13 +124,14 @@ export function SubagentList({ sessionId, isActiveSession }: SubagentListProps)
|
|
|
123
124
|
</div>
|
|
124
125
|
) : (
|
|
125
126
|
subagents.map((subagent) => {
|
|
126
|
-
const
|
|
127
|
+
const label = subagentRowLabel(subagent);
|
|
128
|
+
const brief = subagent.task ?? subagent.assignment ?? subagent.description ?? label;
|
|
127
129
|
const isViewed = subagent.id === viewedSubagentId;
|
|
128
130
|
return (
|
|
129
131
|
<button
|
|
130
132
|
key={subagent.id}
|
|
131
133
|
type="button"
|
|
132
|
-
title={
|
|
134
|
+
title={brief}
|
|
133
135
|
onClick={() => window.dispatchEvent(new CustomEvent('omp:view-subagent', { detail: { sessionId: String(sessionId), subagent } }))}
|
|
134
136
|
className={`w-full flex items-center text-left text-xs rounded-md px-2 py-1 cursor-pointer transition-colors select-none group/subagent ${isViewed ? 'bg-ink/10 font-medium text-ink' : 'text-ink/70 hover:text-ink hover:bg-ink/5'}`}
|
|
135
137
|
>
|
|
@@ -141,8 +143,8 @@ export function SubagentList({ sessionId, isActiveSession }: SubagentListProps)
|
|
|
141
143
|
{/* Gap between subagent icon and task text */}
|
|
142
144
|
<span className="w-2 shrink-0" />
|
|
143
145
|
|
|
144
|
-
{/* Subagent
|
|
145
|
-
<span className="flex-1 min-w-0 truncate leading-snug">{
|
|
146
|
+
{/* Subagent label: `id (agent): target` */}
|
|
147
|
+
<span className="flex-1 min-w-0 truncate leading-snug">{label}</span>
|
|
146
148
|
</button>
|
|
147
149
|
);
|
|
148
150
|
})
|
|
@@ -32,6 +32,7 @@ interface ComposerDockProps {
|
|
|
32
32
|
accessMode: ApprovalMode;
|
|
33
33
|
onAccessModeChange: (mode: ApprovalMode) => void;
|
|
34
34
|
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
35
|
+
deferredComposerPickRef: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
|
|
35
36
|
sessionModel: { provider: string; modelId: string } | null;
|
|
36
37
|
sessionThinkingLevel?: string | null;
|
|
37
38
|
variant: 'desktop' | 'mobile';
|
|
@@ -69,6 +70,7 @@ export function ComposerDock({
|
|
|
69
70
|
accessMode,
|
|
70
71
|
onAccessModeChange,
|
|
71
72
|
composerModelRef,
|
|
73
|
+
deferredComposerPickRef,
|
|
72
74
|
sessionModel,
|
|
73
75
|
sessionThinkingLevel,
|
|
74
76
|
variant,
|
|
@@ -118,6 +120,7 @@ export function ComposerDock({
|
|
|
118
120
|
accessMode={accessMode}
|
|
119
121
|
onAccessModeChange={onAccessModeChange}
|
|
120
122
|
composerModelRef={composerModelRef}
|
|
123
|
+
deferredComposerPickRef={deferredComposerPickRef}
|
|
121
124
|
sessionModel={sessionModel}
|
|
122
125
|
sessionThinkingLevel={sessionThinkingLevel}
|
|
123
126
|
variant={variant}
|
|
@@ -40,6 +40,7 @@ interface EmptyWorkspacePromptProps {
|
|
|
40
40
|
accessMode: ApprovalMode;
|
|
41
41
|
onAccessModeChange: (mode: ApprovalMode) => void;
|
|
42
42
|
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
43
|
+
deferredComposerPickRef: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
|
|
43
44
|
generatingVerb?: string;
|
|
44
45
|
variant?: 'desktop' | 'mobile';
|
|
45
46
|
}
|
|
@@ -69,6 +70,7 @@ export function EmptyWorkspacePrompt({
|
|
|
69
70
|
accessMode,
|
|
70
71
|
onAccessModeChange,
|
|
71
72
|
composerModelRef,
|
|
73
|
+
deferredComposerPickRef,
|
|
72
74
|
generatingVerb,
|
|
73
75
|
variant = 'desktop',
|
|
74
76
|
}: EmptyWorkspacePromptProps) {
|
|
@@ -226,6 +228,7 @@ export function EmptyWorkspacePrompt({
|
|
|
226
228
|
accessMode={accessMode}
|
|
227
229
|
onAccessModeChange={onAccessModeChange}
|
|
228
230
|
composerModelRef={composerModelRef}
|
|
231
|
+
deferredComposerPickRef={deferredComposerPickRef}
|
|
229
232
|
variant={variant}
|
|
230
233
|
/>
|
|
231
234
|
</div>
|
|
@@ -8,6 +8,7 @@ import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter
|
|
|
8
8
|
import { useSubagentTranscript } from '@/client/hooks/chat/subagent';
|
|
9
9
|
import { useModelNames } from '@/client/hooks/models/use-model-names';
|
|
10
10
|
import { isNoticeRow } from '@/shared/lib/chat/notice-row';
|
|
11
|
+
import { subagentHeaderTitle } from '@/shared/lib/omp/subagent/label';
|
|
11
12
|
import { previousNonNoticeIndex, resolveRunFooters, streamingRowIndex } from '@/shared/lib/chat/timeline/run-footer';
|
|
12
13
|
import { formatCompactTokens } from '@/shared/lib/format/number';
|
|
13
14
|
|
|
@@ -62,6 +63,7 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
|
|
|
62
63
|
|
|
63
64
|
const progress = status?.progress ?? subagent.progress;
|
|
64
65
|
const modelName = progress?.resolvedModel ?? subagent.agent;
|
|
66
|
+
const headerTitle = subagentHeaderTitle(status ?? subagent);
|
|
65
67
|
const compactTokens = formatCompactTokens(progress?.tokens);
|
|
66
68
|
const meta = compactTokens ? `${compactTokens} tok` : '';
|
|
67
69
|
const rawTask = status?.task ?? status?.assignment ?? status?.description
|
|
@@ -85,7 +87,7 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
|
|
|
85
87
|
<div className="min-w-0 flex-1">
|
|
86
88
|
<div className="flex items-center gap-2 min-w-0">
|
|
87
89
|
<span className="font-mono text-[12px] font-semibold text-ink truncate">
|
|
88
|
-
{
|
|
90
|
+
{headerTitle}
|
|
89
91
|
</span>
|
|
90
92
|
<span className="shrink-0 rounded bg-ink/5 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-ink/50">
|
|
91
93
|
{STATUS_LABEL[effectiveStatus]}
|