ompchamber 0.8.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/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/index.6e065017eb.js +33 -0
- package/package.json +2 -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/CategoryItem.tsx +1 -1
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- 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 +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- 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/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/composer/index.ts +28 -8
- package/src/client/hooks/chat/omp/session-list.tsx +19 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +47 -28
- 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 +25 -21
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- 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/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- 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/manager.ts +58 -3
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
- package/src/server/lib/queue/delivery.server.test.ts +203 -0
- package/src/server/lib/queue/delivery.server.ts +144 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- 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/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- 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/session/stream-state.server.ts +2 -3
- 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 +4 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -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/agent.ts +12 -0
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +2 -2
- package/dist/client/static/css/index.5c0447cefe.css +0 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
- package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
- package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.dc5f5fabe8.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
|
|
@@ -177,10 +187,11 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
177
187
|
|
|
178
188
|
const {
|
|
179
189
|
messageQueue,
|
|
180
|
-
|
|
190
|
+
enqueueMessage,
|
|
191
|
+
removeMessage,
|
|
192
|
+
reorderMessages,
|
|
181
193
|
steeringQueue,
|
|
182
194
|
setSteeringQueue,
|
|
183
|
-
removeDeliveredFromQueue,
|
|
184
195
|
} = useChatTimelineQueue(sessionId);
|
|
185
196
|
const {
|
|
186
197
|
pending: extensionDialogs,
|
|
@@ -190,7 +201,6 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
190
201
|
} = useExtensionDialogQueue(sessionId);
|
|
191
202
|
|
|
192
203
|
const ompAgent = useOmpAgent(isOmpSession ? sessionId : null, createOmpAgentCallbacks({
|
|
193
|
-
removeDeliveredFromQueue,
|
|
194
204
|
setGenerating,
|
|
195
205
|
setGeneratingVerb,
|
|
196
206
|
scrollToBottom,
|
|
@@ -229,28 +239,18 @@ 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,
|
|
236
246
|
setSearchParams,
|
|
237
247
|
});
|
|
238
248
|
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// explicit send disarms it.
|
|
245
|
-
useEffect(() => {
|
|
246
|
-
if (stopHoldRef.current) return;
|
|
247
|
-
if (!isGenerating && messageQueue.length > 0) {
|
|
248
|
-
const nextMessage = messageQueue[0];
|
|
249
|
-
setMessageQueue(q => q.slice(1));
|
|
250
|
-
// The queued snapshot re-applies model/thinking/access before the prompt.
|
|
251
|
-
executeSend(nextMessage.text, nextMessage.attachments, { model: nextMessage.model });
|
|
252
|
-
}
|
|
253
|
-
}, [isGenerating, messageQueue, executeSend, setMessageQueue]);
|
|
249
|
+
// Queue auto-delivery moved server-side: the wrapper's terminal `agent_end`
|
|
250
|
+
// schedules the next queued prompt (`lib/queue/delivery.server.ts`), with a
|
|
251
|
+
// transactional head claim so no reload/tab race can double-send or wipe the
|
|
252
|
+
// table. The client panel is a view; the mount nudge lives in the queue
|
|
253
|
+
// hook. `stopHoldRef` remains only for the mock path below.
|
|
254
254
|
|
|
255
255
|
const {
|
|
256
256
|
handleSend,
|
|
@@ -271,7 +271,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
271
271
|
sessionId,
|
|
272
272
|
appSettings,
|
|
273
273
|
messageQueue,
|
|
274
|
-
|
|
274
|
+
enqueueMessage,
|
|
275
|
+
removeMessage,
|
|
275
276
|
executeSend,
|
|
276
277
|
steerOmpAgent,
|
|
277
278
|
ompAgent,
|
|
@@ -283,6 +284,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
283
284
|
pendingComposerModelRef,
|
|
284
285
|
pendingThinkingLevelRef,
|
|
285
286
|
composerModelRef,
|
|
287
|
+
deferredComposerPickRef,
|
|
286
288
|
accessModeRef,
|
|
287
289
|
setSearchParams,
|
|
288
290
|
});
|
|
@@ -303,7 +305,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
303
305
|
loadOlder,
|
|
304
306
|
generatingVerb,
|
|
305
307
|
messageQueue,
|
|
306
|
-
|
|
308
|
+
removeMessage,
|
|
309
|
+
reorderMessages,
|
|
307
310
|
steeringQueue,
|
|
308
311
|
setSteeringQueue,
|
|
309
312
|
inputValue,
|
|
@@ -329,6 +332,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
329
332
|
accessMode,
|
|
330
333
|
handleAccessModeChange,
|
|
331
334
|
composerModelRef,
|
|
335
|
+
deferredComposerPickRef,
|
|
332
336
|
extensionDialogs,
|
|
333
337
|
resolveExtensionDialog,
|
|
334
338
|
respondToExtensionUi: ompAgent.respondToExtensionUi,
|
|
@@ -4,84 +4,148 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Client view of the follow-up queue (SQLite `queued_messages`, owned by the
|
|
8
|
+
* server). The panel is a VIEW: mutations go through the per-item endpoints
|
|
9
|
+
* (`POST/PATCH/DELETE /api/sessions/:id/queue[/:itemId]`) and every response
|
|
10
|
+
* carries the canonical queue, which replaces local state wholesale. The old
|
|
11
|
+
* client-side replace-on-write mirror is gone — it was what let a reload
|
|
12
|
+
* hydrating over an empty state wipe the table, and a session switch PUT one
|
|
13
|
+
* session's list into another's rows.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* hydrates from the table on mount — a page reload mid-stream restores it.
|
|
16
|
-
* Steering mirrors live in the session-state blob under a separate key.
|
|
17
|
-
*
|
|
18
|
-
* Kept out of useChatTimeline so that hook stays under the size ceiling.
|
|
15
|
+
* Steering still mirrors into the per-session session-state blob (client-only
|
|
16
|
+
* by design: an interrupt-and-reply is only meaningful while a client watches
|
|
17
|
+
* the run).
|
|
19
18
|
*/
|
|
20
19
|
|
|
21
|
-
import type { SetStateAction } from 'preact/compat';
|
|
22
20
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
23
21
|
import type { QueuedMessage } from '@/shared/types';
|
|
24
22
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
25
23
|
|
|
26
24
|
const STEERING_STATE_KEY = 'chat.steeringQueue';
|
|
27
25
|
|
|
26
|
+
function queueUrl(sessionId: string): string {
|
|
27
|
+
return `/api/sessions/${encodeURIComponent(sessionId)}/queue`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readQueueResponse(res: Response): Promise<QueuedMessage[] | null> {
|
|
31
|
+
if (!res.ok) return null;
|
|
32
|
+
const data = (await res.json().catch(() => null)) as { queue?: unknown } | null;
|
|
33
|
+
return Array.isArray(data?.queue) ? (data.queue as QueuedMessage[]) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
export interface ChatTimelineQueueResult {
|
|
29
37
|
messageQueue: QueuedMessage[];
|
|
30
|
-
|
|
38
|
+
/** Append one item; local state updates optimistically, then reconciles
|
|
39
|
+
* from the canonical response. No-op without a session. */
|
|
40
|
+
enqueueMessage: (item: Omit<QueuedMessage, 'id'>) => void;
|
|
41
|
+
/** Edit one item's text (PATCH). */
|
|
42
|
+
editMessageText: (id: string, text: string) => void;
|
|
43
|
+
/** Remove one item by id (DELETE). */
|
|
44
|
+
removeMessage: (id: string) => void;
|
|
45
|
+
/** Reorder the queue to these ids (PUT, drag-and-drop). */
|
|
46
|
+
reorderMessages: (orderedIds: string[]) => void;
|
|
47
|
+
/** Replace the local list from a fetch (mount hydration, manual refresh). */
|
|
48
|
+
refresh: () => void;
|
|
49
|
+
/** Client-only steering mirror (session-state blob). */
|
|
31
50
|
steeringQueue: QueuedMessage[];
|
|
32
|
-
setSteeringQueue: (
|
|
33
|
-
/** Drop an exactly-matching text from whichever mirror queue holds it. */
|
|
34
|
-
removeDeliveredFromQueue: (text: string) => void;
|
|
51
|
+
setSteeringQueue: (value: QueuedMessage[] | ((prev: QueuedMessage[]) => QueuedMessage[])) => void;
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
export function useChatTimelineQueue(sessionId: string | null): ChatTimelineQueueResult {
|
|
38
55
|
const [messageQueue, setMessageQueueState] = useState<QueuedMessage[]>([]);
|
|
39
56
|
const [steeringQueue, setSteeringQueue] = useSessionState<QueuedMessage[]>(STEERING_STATE_KEY, []);
|
|
40
57
|
|
|
41
|
-
// Hydrate from the table once per session
|
|
42
|
-
//
|
|
58
|
+
// Hydrate from the table once per session, and re-read when the tab
|
|
59
|
+
// regains focus (a second tab may have claimed/delivered the head). A
|
|
60
|
+
// generation-safe ref skips the stale-response race on quick switches.
|
|
43
61
|
const hydrateEpochRef = useRef(0);
|
|
44
|
-
|
|
62
|
+
const cancelledRef = useRef(false);
|
|
63
|
+
const refresh = useCallback(() => {
|
|
45
64
|
if (!sessionId) {
|
|
46
65
|
setMessageQueueState([]);
|
|
47
66
|
return;
|
|
48
67
|
}
|
|
49
68
|
const epoch = ++hydrateEpochRef.current;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
.then(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
setMessageQueueState(Array.isArray(data.queue) ? data.queue : []);
|
|
69
|
+
fetch(queueUrl(sessionId))
|
|
70
|
+
.then((res) => readQueueResponse(res))
|
|
71
|
+
.then((queue) => {
|
|
72
|
+
if (cancelledRef.current || hydrateEpochRef.current !== epoch) return;
|
|
73
|
+
setMessageQueueState(queue ?? []);
|
|
56
74
|
})
|
|
57
75
|
.catch(() => {});
|
|
58
|
-
return () => { cancelled = true; };
|
|
59
76
|
}, [sessionId]);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
cancelledRef.current = false;
|
|
79
|
+
refresh();
|
|
80
|
+
const onFocus = () => refresh();
|
|
81
|
+
window.addEventListener('focus', onFocus);
|
|
82
|
+
return () => {
|
|
83
|
+
cancelledRef.current = true;
|
|
84
|
+
window.removeEventListener('focus', onFocus);
|
|
85
|
+
};
|
|
86
|
+
}, [refresh]);
|
|
60
87
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
88
|
+
// Mounted session with queued items: offer the server a delivery slot. The
|
|
89
|
+
// server-side claim makes this harmless when a run is active or another tab
|
|
90
|
+
// already took the head — this used to be the client auto-delivery effect,
|
|
91
|
+
// which fired into whatever session was open at the time.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!sessionId || messageQueue.length === 0) return;
|
|
94
|
+
fetch(`${queueUrl(sessionId)}/deliver`, { method: 'POST' }).catch(() => {});
|
|
95
|
+
}, [sessionId, messageQueue.length > 0]);
|
|
96
|
+
|
|
97
|
+
const enqueueMessage = useCallback((item: Omit<QueuedMessage, 'id'>) => {
|
|
64
98
|
if (!sessionId) return;
|
|
65
|
-
|
|
66
|
-
|
|
99
|
+
const optimistic: QueuedMessage = { ...item, id: `queue-${Date.now()}` };
|
|
100
|
+
setMessageQueueState((prev) => [...prev, optimistic]);
|
|
101
|
+
fetch(queueUrl(sessionId), {
|
|
102
|
+
method: 'POST',
|
|
67
103
|
headers: { 'Content-Type': 'application/json' },
|
|
68
|
-
body: JSON.stringify(
|
|
69
|
-
})
|
|
104
|
+
body: JSON.stringify(item),
|
|
105
|
+
})
|
|
106
|
+
.then((res) => readQueueResponse(res))
|
|
107
|
+
.then((queue) => { if (queue) setMessageQueueState(queue); })
|
|
108
|
+
.catch(() => {});
|
|
70
109
|
}, [sessionId]);
|
|
71
110
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
111
|
+
const editMessageText = useCallback((id: string, text: string) => {
|
|
112
|
+
if (!sessionId) return;
|
|
113
|
+
fetch(`${queueUrl(sessionId)}/${encodeURIComponent(id)}`, {
|
|
114
|
+
method: 'PATCH',
|
|
115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
116
|
+
body: JSON.stringify({ text }),
|
|
117
|
+
})
|
|
118
|
+
.then((res) => readQueueResponse(res))
|
|
119
|
+
.then((queue) => { if (queue) setMessageQueueState(queue); })
|
|
120
|
+
.catch(() => {});
|
|
121
|
+
}, [sessionId]);
|
|
122
|
+
|
|
123
|
+
const removeMessage = useCallback((id: string) => {
|
|
124
|
+
if (!sessionId) return;
|
|
125
|
+
// Optimistic removal, reconciled only on failure (404 = already claimed —
|
|
126
|
+
// the item is gone either way).
|
|
127
|
+
setMessageQueueState((prev) => prev.filter((i) => i.id !== id));
|
|
128
|
+
fetch(`${queueUrl(sessionId)}/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
129
|
+
.then((res) => readQueueResponse(res))
|
|
130
|
+
.then((queue) => { if (queue) setMessageQueueState(queue); })
|
|
131
|
+
.catch(() => {});
|
|
132
|
+
}, [sessionId]);
|
|
79
133
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
134
|
+
const reorderMessages = useCallback((orderedIds: string[]) => {
|
|
135
|
+
if (!sessionId) return;
|
|
136
|
+
setMessageQueueState((prev) => {
|
|
137
|
+
const byId = new Map(prev.map((i) => [i.id, i]));
|
|
138
|
+
return orderedIds.map((id) => byId.get(id)).filter((i): i is QueuedMessage => Boolean(i));
|
|
139
|
+
});
|
|
140
|
+
fetch(queueUrl(sessionId), {
|
|
141
|
+
method: 'PUT',
|
|
142
|
+
headers: { 'Content-Type': 'application/json' },
|
|
143
|
+
body: JSON.stringify({ orderedIds }),
|
|
144
|
+
})
|
|
145
|
+
.then((res) => readQueueResponse(res))
|
|
146
|
+
.then((queue) => { if (queue) setMessageQueueState(queue); })
|
|
147
|
+
.catch(() => {});
|
|
148
|
+
}, [sessionId]);
|
|
85
149
|
|
|
86
|
-
return { messageQueue,
|
|
150
|
+
return { messageQueue, enqueueMessage, editMessageText, removeMessage, reorderMessages, refresh, steeringQueue, setSteeringQueue };
|
|
87
151
|
}
|