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
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
55
|
-
*
|
|
56
|
-
|
|
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,
|
|
2
|
-
import
|
|
3
|
-
|
|
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 {
|
|
8
|
+
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
6
9
|
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
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
|
-
/**
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
29
|
-
normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
|
|
42
|
+
const seed = useMemo(
|
|
43
|
+
() => normalizePanelWidths(appSettings.desktopLayoutSizes, legacyView),
|
|
44
|
+
[appSettings.desktopLayoutSizes, legacyView],
|
|
30
45
|
);
|
|
31
|
-
const
|
|
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
|
-
|
|
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
|
-
[
|
|
52
|
+
[setWidths],
|
|
50
53
|
);
|
|
51
54
|
|
|
52
|
-
|
|
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
|
+
}
|