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.
Files changed (64) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b859cc1cc5.css +2 -0
  3. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
  4. package/dist/client/static/js/index.6e065017eb.js +33 -0
  5. package/package.json +1 -1
  6. package/src/client/components/common/ComposerPicker.tsx +16 -4
  7. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  8. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  9. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  10. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  11. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  12. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  13. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  14. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  15. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  16. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  17. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  18. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +3 -0
  19. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  20. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  21. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  22. package/src/client/components/workspace/chat-timeline/index.tsx +3 -0
  23. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  24. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  25. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  26. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  27. package/src/client/hooks/chat/composer/index.ts +28 -8
  28. package/src/client/hooks/chat/timeline/actions.ts +24 -3
  29. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  30. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  31. package/src/client/hooks/chat/timeline/index.ts +13 -1
  32. package/src/client/hooks/chat/timeline/send.ts +16 -5
  33. package/src/client/hooks/workspace/available-width.ts +33 -0
  34. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  35. package/src/server/lib/models/preferences.server.ts +110 -0
  36. package/src/server/lib/models/registry.server.ts +206 -0
  37. package/src/server/lib/omp/rpc/utility.ts +7 -1
  38. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  39. package/src/server/lib/queue/delivery.server.ts +38 -8
  40. package/src/server/routes/models/root.ts +88 -215
  41. package/src/server/routes/settings/commands.ts +59 -18
  42. package/src/shared/lib/chat/composer/client.ts +17 -8
  43. package/src/shared/lib/chat/composer/commands.ts +290 -0
  44. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  45. package/src/shared/lib/chat/composer/filter.ts +21 -7
  46. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  47. package/src/shared/lib/models/preferences.test.ts +131 -0
  48. package/src/shared/lib/models/preferences.ts +88 -0
  49. package/src/shared/lib/models/provider-models.ts +8 -1
  50. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  51. package/src/shared/lib/omp/subagent/label.ts +84 -0
  52. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  53. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  54. package/src/shared/lib/workspace/right-panels.ts +50 -11
  55. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  56. package/src/shared/types/chat.ts +38 -65
  57. package/src/shared/types/index.ts +1 -0
  58. package/src/shared/types/models.ts +101 -0
  59. package/src/shared/types/omp/subagent.ts +4 -0
  60. package/src/shared/types/settings/command.ts +13 -0
  61. package/dist/client/static/css/index.e880517fcd.css +0 -2
  62. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  63. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  64. /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, TargetedPointerEvent } from 'preact';
5
- import { clamp, toPx, transfer } from '@/client/components/layout/desktop-layout/resizer/utils';
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
- groupRef?: RefObject<GroupImperativeHandle | null>;
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
- collapsible?: boolean;
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 ref={elementRef} data-panel="" data-panel-id={id} className={className} style={{ ...baseStyle, ...style }}>
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
- <div
314
- ref={hostRef}
315
- data-separator=""
316
- role="separator"
317
- aria-orientation={orientation === 'horizontal' ? 'vertical' : 'horizontal'}
318
- className={className}
319
- style={style}
320
- onPointerDown={handlePointerDown}
321
- onDragStart={(e: DragEvent) => e.preventDefault()}
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-12 flex-shrink-0 flex items-center px-4 border-b border-ink/10 titlebar-drag-region select-none"
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: the run-status indicator (spinner /
56
- check) shows at rest, and the expand toggle takes the slot over on
57
- hover — or permanently while the roster is open. Never two icons.
58
- The toggle is pointer-inert while invisible so the slot keeps
59
- selecting the session until the chevron is what you see. */}
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 text-ink/40 transition-opacity hover:text-ink ${
71
- isExpanded
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} className="text-ink/70" /> : <ChevronRight size={13} />}
90
+ {isExpanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
77
91
  </button>
78
92
  )}
79
- {(awaitingInput || status) && !(showChevron && isExpanded) && (
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 taskText = subagent.task ?? subagent.description ?? subagent.assignment ?? subagent.agent ?? '';
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={taskText}
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 task description text */}
145
- <span className="flex-1 min-w-0 truncate leading-snug">{taskText}</span>
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
- {status?.agent ?? subagent.agent}
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]}