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.
Files changed (130) 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/async/g.a06ca3b3e9.js +2 -0
  5. package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
  6. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  7. package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
  8. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  9. package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
  10. package/dist/client/static/js/index.6e065017eb.js +33 -0
  11. package/package.json +2 -1
  12. package/src/client/components/common/ComposerPicker.tsx +16 -4
  13. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  14. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  15. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  16. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  17. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  18. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  19. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  20. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  21. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
  22. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  23. package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
  24. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  25. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  26. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  29. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
  30. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  31. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  32. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  33. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  34. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  36. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  37. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  38. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  39. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  40. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  41. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  42. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  43. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  44. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  45. package/src/client/data/theme/terminal.ts +27 -29
  46. package/src/client/hooks/chat/composer/index.ts +28 -8
  47. package/src/client/hooks/chat/omp/session-list.tsx +19 -2
  48. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  49. package/src/client/hooks/chat/timeline/actions.ts +47 -28
  50. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  51. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  52. package/src/client/hooks/chat/timeline/index.ts +25 -21
  53. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  54. package/src/client/hooks/chat/timeline/send.ts +16 -5
  55. package/src/client/hooks/workspace/available-width.ts +33 -0
  56. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  57. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  58. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  59. package/src/server/db.server.ts +19 -0
  60. package/src/server/lib/fs/git-sync.ts +191 -0
  61. package/src/server/lib/models/preferences.server.ts +110 -0
  62. package/src/server/lib/models/registry.server.ts +206 -0
  63. package/src/server/lib/omp/rpc/manager.ts +58 -3
  64. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  65. package/src/server/lib/omp/rpc/utility.ts +7 -1
  66. package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
  67. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  68. package/src/server/lib/queue/delivery.server.ts +144 -0
  69. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  70. package/src/server/lib/queue/store.server.ts +185 -0
  71. package/src/server/lib/terminal/foreground.ts +73 -0
  72. package/src/server/lib/terminal/history.test.ts +118 -0
  73. package/src/server/lib/terminal/history.ts +205 -0
  74. package/src/server/lib/terminal/runtime.server.ts +338 -0
  75. package/src/server/lib/terminal/session.server.ts +215 -0
  76. package/src/server/lib/terminal/shell.test.ts +97 -0
  77. package/src/server/lib/terminal/shell.ts +103 -0
  78. package/src/server/routes/fs/git.ts +22 -12
  79. package/src/server/routes/index.ts +2 -1
  80. package/src/server/routes/models/root.ts +88 -215
  81. package/src/server/routes/sessions/index.ts +11 -3
  82. package/src/server/routes/sessions/queue.ts +121 -0
  83. package/src/server/routes/sessions/session.ts +1 -132
  84. package/src/server/routes/settings/commands.ts +59 -18
  85. package/src/server/routes/terminal/index.ts +2 -2
  86. package/src/server/routes/terminal/run.ts +8 -100
  87. package/src/server/routes/terminal/sessions.ts +24 -0
  88. package/src/server/routes/terminal/ws.ts +149 -0
  89. package/src/shared/lib/chat/composer/client.ts +17 -8
  90. package/src/shared/lib/chat/composer/commands.ts +290 -0
  91. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  92. package/src/shared/lib/chat/composer/filter.ts +21 -7
  93. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  94. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  95. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  96. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  97. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  98. package/src/shared/lib/models/preferences.test.ts +131 -0
  99. package/src/shared/lib/models/preferences.ts +88 -0
  100. package/src/shared/lib/models/provider-models.ts +8 -1
  101. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  102. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  103. package/src/shared/lib/omp/subagent/label.ts +84 -0
  104. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  105. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  106. package/src/shared/lib/workspace/right-panels.ts +50 -11
  107. package/src/shared/lib/workspace/session-state/store.ts +4 -4
  108. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  109. package/src/shared/types/chat.ts +38 -65
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/models.ts +101 -0
  112. package/src/shared/types/omp/agent.ts +12 -0
  113. package/src/shared/types/omp/subagent.ts +4 -0
  114. package/src/shared/types/settings/command.ts +13 -0
  115. package/src/shared/types/terminal.ts +17 -15
  116. package/src/shared/types/workspace.ts +2 -2
  117. package/dist/client/static/css/index.5c0447cefe.css +0 -2
  118. package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
  119. package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
  120. package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
  121. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  122. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  123. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  124. package/src/client/hooks/workspace/terminal.ts +0 -283
  125. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  126. package/src/server/routes/terminal/stream.ts +0 -100
  127. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  128. /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
  129. /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  130. /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
- setMessageQueue,
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
- composerModelRef,
242
+ deferredComposerPickRef,
233
243
  accessModeRef,
234
244
  abortControllerRef,
235
245
  setInputValue,
236
246
  setSearchParams,
237
247
  });
238
248
 
239
- // Auto-process queue: when the run ends (or was never active), deliver the
240
- // head item. Both modes queue client-side now — omp sends it as a normal
241
- // prompt via executeSend (which routes to the omp bridge); the mock path
242
- // streams the same way. A user Stop arms `stopHoldRef`: the run ends but the
243
- // queue stays put (stop-all semantics — the toast offers Send now); the next
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
- setMessageQueue,
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
- setMessageQueue,
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
- * Follow-up + steering queue state for the chamber chat, backed by the SQLite
8
- * `queued_messages` table via /api/sessions/:id/queue (one row per item with a
9
- * model snapshot, so auto-delivery replays the settings the item was queued
10
- * with).
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
- * The client panel stays the source of truth while the session is open: every
13
- * mutation updates React state first, then PUTs the full remaining list to the
14
- * route (replace-on-write keeps ids stable for reorder/edit/delete). The queue
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
- setMessageQueue: (updater: SetStateAction<QueuedMessage[]>) => void;
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: (updater: SetStateAction<QueuedMessage[]>) => void;
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. A generation-safe ref skips the
42
- // stale-response race when the user switches sessions quickly.
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
- useEffect(() => {
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
- let cancelled = false;
51
- fetch(`/api/sessions/${encodeURIComponent(sessionId)}/queue`)
52
- .then(res => (res.ok ? res.json() : { queue: [] }))
53
- .then((data: { queue?: QueuedMessage[] }) => {
54
- if (cancelled || hydrateEpochRef.current !== epoch) return;
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
- // Replace-on-write mirror: every client mutation PUTs the full list so the
62
- // table always matches the panel (order, edits, deletions included).
63
- const mirrorQueue = useCallback((next: QueuedMessage[]) => {
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
- fetch(`/api/sessions/${encodeURIComponent(sessionId)}/queue`, {
66
- method: 'PUT',
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({ queue: next }),
69
- }).catch(console.error);
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 setMessageQueue = useCallback((updater: SetStateAction<QueuedMessage[]>) => {
73
- setMessageQueueState(prev => {
74
- const next = typeof updater === 'function' ? updater(prev) : updater;
75
- mirrorQueue(next);
76
- return next;
77
- });
78
- }, [mirrorQueue]);
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 removeDeliveredFromQueue = useCallback((text: string) => {
81
- const exact = (q: QueuedMessage[]) => q.some(i => i.text === text);
82
- setSteeringQueue(q => (exact(q) ? q.filter(i => i.text !== text) : q));
83
- setMessageQueue(q => (exact(q) ? q.filter(i => i.text !== text) : q));
84
- }, [setSteeringQueue, setMessageQueue]);
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, setMessageQueue, steeringQueue, setSteeringQueue, removeDeliveredFromQueue };
150
+ return { messageQueue, enqueueMessage, editMessageText, removeMessage, reorderMessages, refresh, steeringQueue, setSteeringQueue };
87
151
  }