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
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A composer model/thinking pick made while a turn is streaming must not reach
8
+ * the live session: omp applies `set_model` to the RUNNING turn, so pushing it
9
+ * immediately re-targets the answer in flight (verified against omp's RPC
10
+ * handler and by driving a live session — one run answered its first turn on
11
+ * the old model and its next turns on the new one).
12
+ *
13
+ * The pick is held and pushed right before the next prompt instead. These run
14
+ * against a real DOM (happy-dom) because the hooks are Preact hooks: the
15
+ * modules below are imported inside `beforeAll`, after those globals exist,
16
+ * since Preact binds its environment at evaluation time.
17
+ */
18
+
19
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { Window } from 'happy-dom';
21
+ import type { h as PreactH, render as PreactRender } from 'preact';
22
+ import type { act as PreactAct } from 'preact/test-utils';
23
+ import type { useChatTimelineActions as UseChatTimelineActions, ChatTimelineActionsResult } from '@/client/hooks/chat/timeline/actions';
24
+ import type { ComposerModelPick, DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
25
+ import { flushDeferredPick } from '@/client/hooks/chat/timeline/deferred-model';
26
+ import type { OmpAgentHandle } from '@/shared/types';
27
+
28
+ let useChatTimelineActions: typeof UseChatTimelineActions;
29
+ let h: typeof PreactH;
30
+ let render: typeof PreactRender;
31
+ let act: typeof PreactAct;
32
+
33
+ let container: HTMLElement;
34
+
35
+ interface Harness {
36
+ calls: string[];
37
+ deferred: { current: ComposerModelPick | null };
38
+ actions: ChatTimelineActionsResult;
39
+ }
40
+
41
+ /** A recording agent handle plus the calls it received. */
42
+ function makeAgent() {
43
+ const calls: string[] = [];
44
+ const agent = {
45
+ setModel: async (provider: string, modelId: string) => { calls.push(`set_model:${provider}/${modelId}`); },
46
+ setThinkingLevel: async (level: string) => { calls.push(`set_thinking_level:${level}`); },
47
+ } as unknown as OmpAgentHandle;
48
+ return { calls, agent };
49
+ }
50
+
51
+ /** Mounts the real hook and records every model/thinking RPC it pushes. */
52
+ async function mountActions(isGenerating: boolean, stashed: ComposerModelPick | null): Promise<Harness> {
53
+ const deferred: { current: ComposerModelPick | null } = { current: stashed };
54
+ const { calls, agent } = makeAgent();
55
+
56
+ const captured: ChatTimelineActionsResult[] = [];
57
+ const Probe = () => {
58
+ captured.push(useChatTimelineActions({
59
+ inputValue: '',
60
+ setInputValue: () => {},
61
+ setInputAttachments: () => {},
62
+ isGenerating,
63
+ isOmpSession: true,
64
+ sessionId: '01a0ca36-f3be-7252-a95c-3d45d180c539',
65
+ appSettings: {},
66
+ messageQueue: [],
67
+ enqueueMessage: () => {},
68
+ removeMessage: () => {},
69
+ executeSend: async () => {},
70
+ steerOmpAgent: async () => {},
71
+ ompAgent: agent,
72
+ abortControllerRef: { current: null },
73
+ setGenerating: () => {},
74
+ stopHoldRef: { current: false },
75
+ persistMessages: () => {},
76
+ setLocalMessages: () => {},
77
+ pendingComposerModelRef: { current: null },
78
+ pendingThinkingLevelRef: { current: null },
79
+ composerModelRef: { current: null },
80
+ deferredComposerPickRef: deferred,
81
+ accessModeRef: { current: 'yolo' },
82
+ setSearchParams: () => {},
83
+ }));
84
+ return null;
85
+ };
86
+ await act(async () => {
87
+ render(h(Probe, {}), container);
88
+ });
89
+ // The handlers fire-and-forget their RPC, so let the microtask chain settle.
90
+ for (let i = 0; i < 10; i++) await Promise.resolve();
91
+ return { calls, deferred, actions: captured[captured.length - 1] };
92
+ }
93
+
94
+ /** The DOM surface this file installs for Preact to render into. */
95
+ function domGlobals(win: Window): Record<string, unknown> {
96
+ return {
97
+ window: win,
98
+ document: win.document,
99
+ navigator: win.navigator,
100
+ Node: win.Node,
101
+ Element: win.Element,
102
+ HTMLElement: win.HTMLElement,
103
+ };
104
+ }
105
+
106
+ const installed: Record<string, unknown> = {};
107
+ const displaced: Record<string, unknown> = {};
108
+
109
+ beforeAll(async () => {
110
+ const win = new Window({ url: 'http://localhost' });
111
+ for (const [key, value] of Object.entries(domGlobals(win))) {
112
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
113
+ installed[key] = value;
114
+ (globalThis as Record<string, unknown>)[key] = value;
115
+ }
116
+ ({ useChatTimelineActions } = await import('@/client/hooks/chat/timeline/actions'));
117
+ ({ h, render } = await import('preact'));
118
+ ({ act } = await import('preact/test-utils'));
119
+ });
120
+
121
+ // Every test file shares one process, so the globals go back the way they were.
122
+ afterAll(() => {
123
+ for (const key of Object.keys(installed)) {
124
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
125
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
126
+ }
127
+ });
128
+
129
+ beforeEach(() => {
130
+ container = document.createElement('div');
131
+ document.body.appendChild(container);
132
+ });
133
+
134
+ describe('composer model/thinking picks during a stream', () => {
135
+ test('a mid-stream model pick sends no RPC and is stashed', async () => {
136
+ const { calls, deferred, actions } = await mountActions(true, null);
137
+ actions.handleModelChange('kenari', 'deepseek-v4-pro');
138
+
139
+ expect(calls).toEqual([]);
140
+ expect(deferred.current).toEqual({ provider: 'kenari', modelId: 'deepseek-v4-pro' });
141
+ });
142
+
143
+ test('a mid-stream thinking pick sends no RPC and is stashed', async () => {
144
+ const { calls, deferred, actions } = await mountActions(true, null);
145
+ actions.handleThinkingLevelChange('high');
146
+
147
+ expect(calls).toEqual([]);
148
+ expect(deferred.current).toEqual({ thinkingLevel: 'high' });
149
+ });
150
+
151
+ test('model then thinking mid-stream both survive to the next prompt', async () => {
152
+ const { calls, deferred, actions } = await mountActions(true, null);
153
+ actions.handleModelChange('kenari', 'deepseek-v4-pro');
154
+ actions.handleThinkingLevelChange('max');
155
+
156
+ expect(calls).toEqual([]);
157
+ expect(deferred.current).toEqual({ provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max' });
158
+ });
159
+
160
+ test('an idle pick reaches the session immediately', async () => {
161
+ const { calls, deferred, actions } = await mountActions(false, null);
162
+ actions.handleModelChange('kenari', 'deepseek-v4-pro');
163
+ actions.handleThinkingLevelChange('high');
164
+ for (let i = 0; i < 10; i++) await Promise.resolve();
165
+
166
+ expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro', 'set_thinking_level:high']);
167
+ expect(deferred.current).toBeNull();
168
+ });
169
+
170
+ test('an idle model pick leaves a stashed thinking level pending', async () => {
171
+ const { calls, deferred, actions } = await mountActions(false, { thinkingLevel: 'high' });
172
+ actions.handleModelChange('kenari', 'deepseek-v4-pro');
173
+ for (let i = 0; i < 10; i++) await Promise.resolve();
174
+
175
+ // The level was never applied here, so it must still wait for the next
176
+ // prompt rather than being dropped or pushed now.
177
+ expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro']);
178
+ expect(deferred.current).toEqual({ thinkingLevel: 'high' });
179
+ });
180
+
181
+ test("'auto' thinking is never pushed onto the live session", async () => {
182
+ const { calls, deferred, actions } = await mountActions(false, null);
183
+ actions.handleThinkingLevelChange('auto');
184
+
185
+ expect(calls).toEqual([]);
186
+ expect(deferred.current).toBeNull();
187
+ });
188
+ });
189
+
190
+ describe('flushing a pick onto the session', () => {
191
+ test('pushes the stashed model and thinking level, then clears the slot', async () => {
192
+ const { calls, agent } = makeAgent();
193
+ const store: DeferredModelStore = { current: { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'max' } };
194
+
195
+ await flushDeferredPick(agent, store);
196
+
197
+ expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro', 'set_thinking_level:max']);
198
+ expect(store.current).toBeNull();
199
+ });
200
+
201
+ test("a stashed 'auto' level is not pushed as a concrete level", async () => {
202
+ const { calls, agent } = makeAgent();
203
+ const store: DeferredModelStore = { current: { provider: 'kenari', modelId: 'deepseek-v4-pro', thinkingLevel: 'auto' } };
204
+
205
+ await flushDeferredPick(agent, store);
206
+
207
+ expect(calls).toEqual(['set_model:kenari/deepseek-v4-pro']);
208
+ expect(store.current).toBeNull();
209
+ });
210
+
211
+ test('flushing an empty slot sends nothing', async () => {
212
+ const { calls, agent } = makeAgent();
213
+ const store: DeferredModelStore = { current: null };
214
+
215
+ await flushDeferredPick(agent, store);
216
+
217
+ expect(calls).toEqual([]);
218
+ });
219
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Composer model/thinking picks that must NOT reach the live session yet.
8
+ *
9
+ * omp applies `set_model` / `set_thinking_level` to the RUNNING turn: the next
10
+ * LLM call inside the stream (the one after a tool result) goes out on the new
11
+ * model, so a pick made while an answer streams visibly re-targets that answer
12
+ * mid-flight. A composer pick is intent for the NEXT prompt, so while a turn is
13
+ * running it is stashed here and pushed to the session right before the next
14
+ * prompt the composer starts (plain send, steer, or queued delivery).
15
+ *
16
+ * The slot holds only what has NOT reached the session yet, so a pick applied
17
+ * directly (idle composer) clears exactly the fields it applied — a stashed
18
+ * thinking level survives a later model change instead of being resurrected
19
+ * over it.
20
+ *
21
+ * Verified against omp's RPC handler: `set_model` resolves the model and calls
22
+ * the session's `setModel` immediately — nothing defers it to a turn boundary.
23
+ */
24
+
25
+ import type { OmpAgentHandle } from '@/shared/types';
26
+
27
+ /** Fields a composer pick can carry; each handler owns its own. */
28
+ export interface ComposerModelPick {
29
+ provider?: string;
30
+ modelId?: string;
31
+ thinkingLevel?: string;
32
+ }
33
+
34
+ /** Mutable slot the timeline hook owns; the composer and send paths share it. */
35
+ export interface DeferredModelStore {
36
+ current: ComposerModelPick | null;
37
+ }
38
+
39
+ /**
40
+ * Slot that never holds a pick, for composers with no stream to defer against
41
+ * (the New Chat modal): every guard reads it as "nothing pending", so their
42
+ * adoption behaviour stays exactly as it was.
43
+ */
44
+ export const NO_PENDING_PICK: DeferredModelStore = { current: null };
45
+
46
+ /** A pick that names a model must name both halves of it. */
47
+ function hasModel(pick: ComposerModelPick): pick is ComposerModelPick & { provider: string; modelId: string } {
48
+ return Boolean(pick.provider && pick.modelId);
49
+ }
50
+
51
+ /**
52
+ * Record a pick made while a turn is streaming. Merged over the previous stash
53
+ * so a model pick and a later thinking pick both survive to the next prompt.
54
+ */
55
+ export function stashComposerPick(store: DeferredModelStore, pick: ComposerModelPick): void {
56
+ store.current = { ...store.current, ...pick };
57
+ }
58
+
59
+ /**
60
+ * Drop the fields a pick just applied directly, keeping any still-pending ones.
61
+ * Without this, a stashed pick would be flushed over the newer selection on the
62
+ * next send and silently revert the user's latest choice.
63
+ */
64
+ export function consumeComposerPick(store: DeferredModelStore, pick: ComposerModelPick): void {
65
+ const pending = store.current;
66
+ if (!pending) return;
67
+ const next: ComposerModelPick = { ...pending };
68
+ if (pick.provider || pick.modelId) {
69
+ delete next.provider;
70
+ delete next.modelId;
71
+ }
72
+ if (pick.thinkingLevel) delete next.thinkingLevel;
73
+ store.current = next.provider || next.modelId || next.thinkingLevel ? next : null;
74
+ }
75
+
76
+ /**
77
+ * Push a pick onto the live session. `thinkingLevel: 'auto'` means "leave omp's
78
+ * level alone" — the same rule the send path applies — so it is skipped rather
79
+ * than translated into a concrete level.
80
+ */
81
+ export async function applyComposerPick(agent: OmpAgentHandle, pick: ComposerModelPick): Promise<void> {
82
+ if (hasModel(pick)) await agent.setModel(pick.provider, pick.modelId);
83
+ if (pick.thinkingLevel && pick.thinkingLevel !== 'auto') await agent.setThinkingLevel(pick.thinkingLevel);
84
+ }
85
+
86
+ /** Push the stashed pick (if any) onto the session and clear the slot. */
87
+ export async function flushDeferredPick(agent: OmpAgentHandle, store: DeferredModelStore): Promise<void> {
88
+ const pick = store.current;
89
+ if (!pick) return;
90
+ store.current = null;
91
+ await applyComposerPick(agent, pick);
92
+ }
@@ -8,6 +8,7 @@ import { useChatTimelineScroll } from '@/client/hooks/chat/timeline/scroll';
8
8
  import { useTimelineAutoScroll } from '@/client/hooks/chat/timeline/auto-scroll';
9
9
  import { useChatTimelineActions } from '@/client/hooks/chat/timeline/actions';
10
10
  import { useChatTimelineSend } from '@/client/hooks/chat/timeline/send';
11
+ import type { ComposerModelPick } from '@/client/hooks/chat/timeline/deferred-model';
11
12
  import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
12
13
  import { useBrowserPageContextInsert } from '@/client/hooks/chat/timeline/browser-context';
13
14
  import { cancelStreamingCoalescer, createOmpAgentCallbacks } from '@/shared/lib/chat/timeline/omp-callbacks';
@@ -148,6 +149,15 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
148
149
  // Live mirror of the composer's model/thinking pick: the enqueue path
149
150
  // snapshots it onto queued items so auto-delivery replays those settings.
150
151
  const composerModelRef = useRef<{ provider: string; modelId: string; thinkingLevel: string } | null>(null);
152
+ // Picks made WHILE a turn streams: held here so they cannot re-target the
153
+ // answer in flight (omp applies set_model to the running turn), and pushed
154
+ // onto the session right before the next prompt.
155
+ const deferredComposerPickRef = useRef<ComposerModelPick | null>(null);
156
+ // A stashed pick belongs to ONE session's next prompt: carrying it across a
157
+ // switch would silently re-target the session the user just opened.
158
+ useEffect(() => {
159
+ deferredComposerPickRef.current = null;
160
+ }, [sessionId]);
151
161
 
152
162
  // Access-control mode is a global, persisted user preference (unlike the
153
163
  // per-session model/thinking picks): it hydrates from appSettings at first
@@ -229,7 +239,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
229
239
  seedSession,
230
240
  pendingComposerModelRef,
231
241
  pendingThinkingLevelRef,
232
- composerModelRef,
242
+ deferredComposerPickRef,
233
243
  accessModeRef,
234
244
  abortControllerRef,
235
245
  setInputValue,
@@ -274,6 +284,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
274
284
  pendingComposerModelRef,
275
285
  pendingThinkingLevelRef,
276
286
  composerModelRef,
287
+ deferredComposerPickRef,
277
288
  accessModeRef,
278
289
  setSearchParams,
279
290
  });
@@ -321,6 +332,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
321
332
  accessMode,
322
333
  handleAccessModeChange,
323
334
  composerModelRef,
335
+ deferredComposerPickRef,
324
336
  extensionDialogs,
325
337
  resolveExtensionDialog,
326
338
  respondToExtensionUi: ompAgent.respondToExtensionUi,
@@ -17,6 +17,7 @@ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
17
17
  import { streamChatResponse } from '@/client/hooks/chat/stream';
18
18
  import type { SessionSeed } from '@/client/hooks/chat/timeline/session-load';
19
19
  import { buildPromptText } from '@/client/hooks/chat/timeline/prompt-text';
20
+ import { flushDeferredPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
20
21
  import { isTextAttachmentFile } from '@/shared/lib/chat/attachments';
21
22
  import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
22
23
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
@@ -51,9 +52,9 @@ export interface ChatTimelineSendDeps {
51
52
  /** Global access-control mode (persisted user preference), read at send time
52
53
  * so the spawn-capable requests carry the latest value. */
53
54
  accessModeRef: { current: ApprovalMode };
54
- /** Live composer model/thinking mirror (written by ChatInput) so enqueue can
55
- * snapshot the selection onto queued items. */
56
- composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
55
+ /** Picks the composer made while a turn was streaming. Pushed onto the
56
+ * session here, immediately before the prompt they were meant for. */
57
+ deferredComposerPickRef: DeferredModelStore;
57
58
  abortControllerRef: { current: AbortController | null };
58
59
  setInputValue: (v: string) => void;
59
60
  setSearchParams: (fn: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void;
@@ -91,6 +92,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
91
92
  seedSession,
92
93
  pendingComposerModelRef,
93
94
  pendingThinkingLevelRef,
95
+ deferredComposerPickRef,
94
96
  accessModeRef,
95
97
  abortControllerRef,
96
98
  setInputValue,
@@ -133,10 +135,13 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
133
135
 
134
136
  // Steer the running omp agent with a fresh prompt (interrupt-and-reply).
135
137
  const steerOmpAgent = useCallback(async (text: string, attachments: Attachment[]) => {
138
+ // A steer IS a new prompt: a pick made during the interrupted turn belongs
139
+ // to it, so it is pushed before the interrupt-and-reply starts the turn.
140
+ await flushDeferredPick(ompAgent, deferredComposerPickRef);
136
141
  const { promptText, images } = await prepareDeliverable(text, attachments);
137
142
  const ok = await ompAgent.sendInterruptAndReply(promptText, images);
138
143
  if (!ok) setInputValue(text);
139
- }, [ompAgent, prepareDeliverable, setInputValue]);
144
+ }, [ompAgent, prepareDeliverable, setInputValue, deferredComposerPickRef]);
140
145
 
141
146
  const executeSend = useCallback(async (
142
147
  text: string,
@@ -209,6 +214,12 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
209
214
  if (isOmpSession) {
210
215
  aiPlaceholderIdRef.current = aiPlaceholderId;
211
216
  optimisticUserIdRef.current = userMsgId;
217
+ // A model/thinking pick made while the previous turn streamed was held
218
+ // back so it could not re-target that answer. A queued delivery does NOT
219
+ // own it: that item carries its own snapshot, so the pick stays pending
220
+ // for the composer's own next prompt instead of being spent — and then
221
+ // overwritten — by a delivery it was never meant for.
222
+ if (!modelOverride) await flushDeferredPick(ompAgent, deferredComposerPickRef);
212
223
  // Replay the queued snapshot first so the prompt runs on the exact
213
224
  // model/thinking the item was queued with.
214
225
  if (modelOverride) {
@@ -329,6 +340,6 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
329
340
  scrollToBottom,
330
341
  })
331
342
  );
332
- }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, accessModeRef]);
343
+ }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, deferredComposerPickRef, accessModeRef]);
333
344
  return { steerOmpAgent, executeSend };
334
345
  }
@@ -0,0 +1,33 @@
1
+ import { useEffect, useState } from 'preact/hooks';
2
+ import type { RefObject } from 'preact';
3
+
4
+ /**
5
+ * Measure an element's content width and keep it current.
6
+ *
7
+ * Panel widths are stored as a share of the group's available area, so the
8
+ * group has to report that area before any fraction can become pixels. Returns
9
+ * `null` until the first measurement — callers must treat that as "unknown",
10
+ * not as zero, or a panel would collapse on its first render.
11
+ *
12
+ * `ResizeObserver` is not optional here: the width changes on a window resize,
13
+ * on a devtools dock, and on a sibling panel being toggled, and none of those
14
+ * are `window` events.
15
+ */
16
+ export function useAvailableWidth(ref: RefObject<HTMLElement | null>): number | null {
17
+ const [available, setAvailable] = useState<number | null>(null);
18
+
19
+ useEffect(() => {
20
+ const element = ref.current;
21
+ if (!element || typeof ResizeObserver === 'undefined') return;
22
+
23
+ const observer = new ResizeObserver(() => {
24
+ setAvailable(element.clientWidth || null);
25
+ });
26
+ observer.observe(element);
27
+ setAvailable(element.clientWidth || null);
28
+
29
+ return () => observer.disconnect();
30
+ }, [ref]);
31
+
32
+ return available;
33
+ }
@@ -1,59 +1,56 @@
1
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
- import type { RefObject } from 'preact/compat';
3
- import { mergePanelWidths, normalizePanelWidths, type PanelWidths } from '@/shared/lib/workspace/panel-widths';
1
+ import { useCallback, useMemo } from 'preact/hooks';
2
+ import {
3
+ mergePanelWidths,
4
+ normalizePanelWidths,
5
+ type PanelWidths,
6
+ } from '@/shared/lib/workspace/panel-widths';
4
7
  import type { RightPanelType } from '@/shared/lib/workspace/right-panels';
5
- import { writeSetting } from '@/shared/lib/settings/client';
8
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
6
9
 
7
- /** A drag commits once, after the pointer is released; this only coalesces. */
8
- const PERSIST_DEBOUNCE_MS = 500;
10
+ /** Session-state key holding this session's own per-panel widths. */
11
+ const PANEL_WIDTHS_KEY = 'layout.panelWidths';
9
12
 
10
13
  interface PanelWidthsResult {
11
14
  widths: PanelWidths;
12
- /** Live widths, for imperative restores that cannot wait on a re-render. */
13
- widthsRef: RefObject<PanelWidths>;
14
- /** Fold measured panel widths in and persist the merged map. */
15
+ /** Fold measured panel widths into this session's map. */
15
16
  commitWidths: (patch: PanelWidths) => void;
16
17
  }
17
18
 
18
19
  /**
19
- * Owns the desktop layout's per-panel width map: restored from
20
- * `app_settings.desktopLayoutSizes`, kept in a ref for resize handlers, and
21
- * written back (debounced) whenever a panel is resized. One writer for both
22
- * resizable groups, so neither clobbers the other's widths.
20
+ * Owns the desktop layout's per-panel width map.
21
+ *
22
+ * Widths are **per session**: they live in `session_ui_state` under
23
+ * `layout.panelWidths`, next to the rest of a session's layout state
24
+ * (`layout.activeRightPanel`, `layout.showRightPanel`, `layout.openedFiles`), so
25
+ * switching sessions brings back the layout that session was left in.
26
+ *
27
+ * `app_settings.desktopLayoutSizes` is the **seed**: a session that has never
28
+ * been resized reads it, which is what keeps an existing user's layout across
29
+ * the upgrade instead of resetting everyone to the defaults. Once a panel is
30
+ * dragged, the session stores its own map and the seed is no longer consulted
31
+ * for that session.
32
+ *
33
+ * There is deliberately no px→fraction rewrite pass here. Converting a legacy
34
+ * pixel value is done when a width is *read* (`resolvePanelWidth`), because a
35
+ * write would have to run before this session's own blob has loaded and would
36
+ * therefore overwrite it with the seed.
23
37
  */
24
38
  export function usePanelWidths(
25
39
  appSettings: Record<string, any>,
26
40
  legacyView: RightPanelType,
27
41
  ): PanelWidthsResult {
28
- const [widths, setWidths] = useState<PanelWidths>(() =>
29
- normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
42
+ const seed = useMemo(
43
+ () => normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
44
+ [appSettings.desktopLayoutSizes, legacyView],
30
45
  );
31
- const widthsRef = useRef<PanelWidths>(widths);
32
- // Set while a debounced write is pending, which is also the flush condition.
33
- const timerRef = useRef<number | undefined>(undefined);
34
-
35
- const saveNow = useCallback(() => {
36
- clearTimeout(timerRef.current);
37
- timerRef.current = undefined;
38
- writeSetting('desktopLayoutSizes', widthsRef.current);
39
- }, []);
46
+ const [widths, setWidths] = useSessionState<PanelWidths>(PANEL_WIDTHS_KEY, seed);
40
47
 
41
48
  const commitWidths = useCallback(
42
49
  (patch: PanelWidths) => {
43
- const next = mergePanelWidths(widthsRef.current, patch);
44
- widthsRef.current = next;
45
- setWidths(next);
46
- clearTimeout(timerRef.current);
47
- timerRef.current = window.setTimeout(saveNow, PERSIST_DEBOUNCE_MS);
50
+ setWidths((prev) => mergePanelWidths(prev, patch));
48
51
  },
49
- [saveNow],
52
+ [setWidths],
50
53
  );
51
54
 
52
- // The mobile layout unmounts this hook; flush instead of dropping the width
53
- // the user just dragged.
54
- useEffect(() => () => {
55
- if (timerRef.current !== undefined) saveNow();
56
- }, [saveNow]);
57
-
58
- return { widths, widthsRef, commitWidths };
55
+ return { widths, commitWidths };
59
56
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Persistence for the user's favorite + last-used models.
8
+ *
9
+ * Lives apart from the model registry because a favorite is NOT a property of
10
+ * omp's registry: the catalog the route used to mutate (`omp_models_catalog`)
11
+ * is a MOCK-only demo list that real mode never reads back, so a star clicked
12
+ * on a real install was written into a dead row and lost on the next render.
13
+ * This store is the single source for both modes.
14
+ *
15
+ * Keys are composite (`provider:id`) — see `modelKey` — and stale ones are
16
+ * dropped on read, so a model removed from models.yml cannot hold a slot in the
17
+ * five-entry recent rail forever.
18
+ */
19
+
20
+ import { getDb } from '@/server/db.server';
21
+ import { readSettingsJson, writeSettingsJson } from '@/server/lib/db/settings-store';
22
+ import { isMockMode } from '@/server/mock.server';
23
+ import { INITIAL_MODELS_CATALOG } from '@/client/data/models/catalog';
24
+ import {
25
+ EMPTY_MODEL_PREFERENCES,
26
+ RECENT_MODELS_LIMIT,
27
+ filterKnownKeys,
28
+ readModelPreferences,
29
+ recordRecentKey,
30
+ toggleFavoriteKey,
31
+ } from '@/shared/lib/models/preferences';
32
+ import { modelKey } from '@/shared/lib/models/identity';
33
+ import type { ModelPreferences } from '@/shared/types';
34
+
35
+ const MODEL_PREFERENCES_KEY = 'omp_model_preferences';
36
+
37
+ /**
38
+ * Demo favorites/recents so a `MOCK=true` chamber still opens with the rails
39
+ * the shipped catalog advertises. Seeded on first read only — from then on the
40
+ * row is the user's, and the catalog's own flags are ignored.
41
+ */
42
+ function mockSeedPreferences(): ModelPreferences {
43
+ return {
44
+ favorites: INITIAL_MODELS_CATALOG.filter((model) => model.isFavorite).map(modelKey),
45
+ recentKeys: INITIAL_MODELS_CATALOG.filter((model) => model.isRecent).map(modelKey).slice(0, RECENT_MODELS_LIMIT),
46
+ };
47
+ }
48
+
49
+ async function readStoredPreferences(): Promise<ModelPreferences> {
50
+ const db = await getDb();
51
+ const raw = await readSettingsJson<unknown>(db, MODEL_PREFERENCES_KEY, null);
52
+ if (raw === null) return isMockMode() ? mockSeedPreferences() : EMPTY_MODEL_PREFERENCES;
53
+ return readModelPreferences(raw);
54
+ }
55
+
56
+ /**
57
+ * Preferences narrowed to the models the registry actually serves right now —
58
+ * the shape every loader response carries.
59
+ */
60
+ export async function readModelPreferencesFor(
61
+ models: readonly { provider: string; id: string }[],
62
+ ): Promise<ModelPreferences> {
63
+ try {
64
+ const known = new Set(models.map(modelKey));
65
+ const stored = await readStoredPreferences();
66
+ return {
67
+ favorites: filterKnownKeys(stored.favorites, known),
68
+ recentKeys: filterKnownKeys(stored.recentKeys, known),
69
+ };
70
+ } catch {
71
+ return EMPTY_MODEL_PREFERENCES;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Persist a mutation with the stale keys already removed, so a key the registry
77
+ * dropped cannot sit in the row holding one of the five recent slots. Reading
78
+ * prunes for display only — without this the slot stayed occupied across
79
+ * reloads and the RECENT rail silently showed four models instead of five.
80
+ */
81
+ async function writePreferences(known: ReadonlySet<string>, mutate: (current: ModelPreferences) => ModelPreferences): Promise<ModelPreferences> {
82
+ const current = await readStoredPreferences();
83
+ const next = mutate({
84
+ favorites: filterKnownKeys(current.favorites, known),
85
+ recentKeys: filterKnownKeys(current.recentKeys, known),
86
+ });
87
+ const db = await getDb();
88
+ await writeSettingsJson(db, MODEL_PREFERENCES_KEY, next);
89
+ return next;
90
+ }
91
+
92
+ /** Flip one model's favorite flag, returning the full persisted pair. */
93
+ export async function toggleFavoritePreference(key: string, known: ReadonlySet<string>): Promise<ModelPreferences> {
94
+ return writePreferences(known, (current) => ({
95
+ ...current,
96
+ favorites: toggleFavoriteKey(current.favorites, key),
97
+ }));
98
+ }
99
+
100
+ /**
101
+ * Record a model as last used. Called from the selection action rather than
102
+ * from a separate endpoint: "the user picked this model" is one event, and a
103
+ * second request would only add a way for the two to disagree.
104
+ */
105
+ export async function recordRecentPreference(key: string, known: ReadonlySet<string>): Promise<ModelPreferences> {
106
+ return writePreferences(known, (current) => ({
107
+ ...current,
108
+ recentKeys: recordRecentKey(current.recentKeys, key),
109
+ }));
110
+ }