ompchamber 0.7.0 → 1.0.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 (165) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.e880517fcd.css +2 -0
  3. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  4. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  5. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  6. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  7. package/dist/client/static/js/async/c.797d933882.js +1 -0
  8. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  9. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  10. package/dist/client/static/js/async/g.a06ca3b3e9.js.LICENSE.txt +11 -0
  11. package/dist/client/static/js/async/{m.4b29d34f93.js → m.c038c71b9f.js} +2 -2
  12. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  13. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  15. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  17. package/dist/client/static/js/async/ot.84c71b308f.js.LICENSE.txt +11 -0
  18. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  19. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  20. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  21. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  22. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  23. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  24. package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +12 -0
  25. package/dist/client/static/js/index.0de15c5e17.js +33 -0
  26. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  27. package/package.json +2 -1
  28. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  29. package/src/client/components/common/code-editor/history.test.ts +118 -0
  30. package/src/client/components/common/code-editor/history.ts +101 -0
  31. package/src/client/components/common/code-editor/index.tsx +56 -66
  32. package/src/client/components/common/code-surface/index.tsx +84 -11
  33. package/src/client/components/common/code-surface/measure.ts +66 -17
  34. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  35. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  36. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +2 -1
  37. package/src/client/components/layout/session-sidebar/SessionItem.tsx +11 -4
  38. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  39. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  40. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  41. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  42. package/src/client/components/settings/LazyModal.tsx +1 -1
  43. package/src/client/components/settings/Modal.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +11 -4
  45. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  46. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  47. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  48. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  49. package/src/client/components/workspace/chat-timeline/index.tsx +124 -108
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  60. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  61. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  62. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  63. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  64. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  65. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  66. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  67. package/src/client/data/theme/terminal.ts +27 -29
  68. package/src/client/hooks/chat/omp/session-list.tsx +23 -2
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  70. package/src/client/hooks/chat/timeline/actions.ts +23 -34
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  72. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  73. package/src/client/hooks/chat/timeline/index.ts +18 -28
  74. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  75. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  76. package/src/client/hooks/chat/timeline/send.ts +13 -27
  77. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  78. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  79. package/src/client/hooks/editor/use-code-window.ts +118 -0
  80. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  81. package/src/client/hooks/ui/notification-sound.ts +65 -0
  82. package/src/client/hooks/ui/theme.ts +22 -6
  83. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  84. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  85. package/src/client/tailwind.css +38 -12
  86. package/src/server/db.server.ts +19 -0
  87. package/src/server/lib/fs/git-sync.ts +191 -0
  88. package/src/server/lib/omp/rpc/manager.ts +72 -6
  89. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  90. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  91. package/src/server/lib/omp/session/sidebar-data.server.ts +10 -4
  92. package/src/server/lib/queue/delivery.server.ts +114 -0
  93. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  94. package/src/server/lib/queue/store.server.ts +185 -0
  95. package/src/server/lib/terminal/foreground.ts +73 -0
  96. package/src/server/lib/terminal/history.test.ts +118 -0
  97. package/src/server/lib/terminal/history.ts +205 -0
  98. package/src/server/lib/terminal/runtime.server.ts +338 -0
  99. package/src/server/lib/terminal/session.server.ts +215 -0
  100. package/src/server/lib/terminal/shell.test.ts +97 -0
  101. package/src/server/lib/terminal/shell.ts +103 -0
  102. package/src/server/routes/fs/git.ts +22 -12
  103. package/src/server/routes/index.ts +2 -1
  104. package/src/server/routes/sessions/index.ts +11 -3
  105. package/src/server/routes/sessions/queue.ts +121 -0
  106. package/src/server/routes/sessions/session.ts +1 -132
  107. package/src/server/routes/settings/commands.ts +1 -1
  108. package/src/server/routes/terminal/index.ts +2 -2
  109. package/src/server/routes/terminal/run.ts +8 -100
  110. package/src/server/routes/terminal/sessions.ts +24 -0
  111. package/src/server/routes/terminal/ws.ts +149 -0
  112. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  113. package/src/shared/lib/chat/ask-questions.ts +264 -0
  114. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  115. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  116. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  117. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  118. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  119. package/src/shared/lib/code/lazy-window.ts +156 -0
  120. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  121. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  122. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  123. package/src/shared/lib/markdown/mermaid.ts +8 -6
  124. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  125. package/src/shared/lib/workspace/session-state/store.ts +1 -4
  126. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  127. package/src/shared/types/omp/agent.ts +14 -2
  128. package/src/shared/types/terminal.ts +17 -15
  129. package/src/shared/types/workspace.ts +6 -2
  130. package/dist/client/static/css/index.51f68210a5.css +0 -2
  131. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  132. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  133. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  134. package/dist/client/static/js/async/g.86e2256a70.js +0 -2
  135. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  136. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  137. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  138. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  139. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  140. package/dist/client/static/js/async/uf.24ca3d49c3.js.LICENSE.txt +0 -6
  141. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  142. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  143. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  144. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  145. package/dist/client/static/js/index.eccad0b70a.js +0 -33
  146. package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
  147. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  148. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  149. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  150. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  151. package/src/client/hooks/workspace/terminal.ts +0 -283
  152. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  153. package/src/server/routes/terminal/stream.ts +0 -100
  154. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  155. /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  156. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  157. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  158. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  159. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → ms.98960af4eb.js.LICENSE.txt} +0 -0
  160. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  161. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  162. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  163. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  164. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.0de15c5e17.js.LICENSE.txt} +0 -0
  165. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -13,19 +13,22 @@
13
13
  * tool call stayed blocked on an approval nobody could answer and the turn never
14
14
  * reached `agent_end` — the run looked hung until Stop.
15
15
  *
16
- * Requests surface one at a time in arrival order; answering the head advances
17
- * the queue. Extracted from useChatTimeline to stay under the size ceiling.
16
+ * The queue keeps EVERY pending request rather than the head alone: `ask` raises
17
+ * one frame per question, and those are rendered inline on the ask tool card
18
+ * (see ask-frames.ts) instead of in a modal, so the head is not the only
19
+ * answerable request at any moment. Entries leave the queue by id once their
20
+ * response is on the wire, or when omp withdraws them (`method: 'cancel'`).
18
21
  */
19
22
 
20
23
  import { useCallback, useEffect, useState } from 'preact/hooks';
21
24
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
22
25
 
23
26
  export interface ExtensionDialogQueue {
24
- /** Head of the queue — the single dialog to render. */
25
- dialog: ExtensionUiDialogRequest | null;
27
+ /** Still-blocking requests, in omp's arrival order. */
28
+ pending: ExtensionUiDialogRequest[];
26
29
  enqueue: (request: ExtensionUiDialogRequest) => void;
27
- /** Advance past the head once its response has been sent. */
28
- dismiss: () => void;
30
+ /** Advance past a request once its response has been sent. */
31
+ resolve: (id: string) => void;
29
32
  /** Drop a request omp withdrew (`method: 'cancel'`) without an answer. */
30
33
  withdraw: (targetId: string) => void;
31
34
  }
@@ -45,12 +48,12 @@ export function useExtensionDialogQueue(sessionId: string | null): ExtensionDial
45
48
  // entry per omp id keeps the queue answerable exactly once.
46
49
  setQueue((current) => (current.some((pending) => pending.id === request.id) ? current : [...current, request]));
47
50
  }, []);
48
- const dismiss = useCallback(() => {
49
- setQueue((current) => current.slice(1));
51
+ const resolve = useCallback((id: string) => {
52
+ setQueue((current) => current.some((pending) => pending.id === id) ? current.filter((pending) => pending.id !== id) : current);
50
53
  }, []);
51
54
  const withdraw = useCallback((targetId: string) => {
52
55
  setQueue((current) => current.filter((pending) => pending.id !== targetId));
53
56
  }, []);
54
57
 
55
- return { dialog: queue[0] ?? null, enqueue, dismiss, withdraw };
58
+ return { pending: queue, enqueue, resolve, withdraw };
56
59
  }
@@ -120,7 +120,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
120
120
  sessionData,
121
121
  adoptedSessionIdRef,
122
122
  refreshSessionMeta,
123
- setSessionModel,
123
+ seedSession,
124
124
  hasMore,
125
125
  loadingOlder,
126
126
  loadOlderError,
@@ -177,20 +177,20 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
177
177
 
178
178
  const {
179
179
  messageQueue,
180
- setMessageQueue,
180
+ enqueueMessage,
181
+ removeMessage,
182
+ reorderMessages,
181
183
  steeringQueue,
182
184
  setSteeringQueue,
183
- removeDeliveredFromQueue,
184
185
  } = useChatTimelineQueue(sessionId);
185
186
  const {
186
- dialog: extensionDialog,
187
+ pending: extensionDialogs,
187
188
  enqueue: enqueueExtensionDialog,
188
- dismiss: dismissExtensionDialog,
189
+ resolve: resolveExtensionDialog,
189
190
  withdraw: withdrawExtensionDialog,
190
191
  } = useExtensionDialogQueue(sessionId);
191
192
 
192
193
  const ompAgent = useOmpAgent(isOmpSession ? sessionId : null, createOmpAgentCallbacks({
193
- removeDeliveredFromQueue,
194
194
  setGenerating,
195
195
  setGeneratingVerb,
196
196
  scrollToBottom,
@@ -226,7 +226,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
226
226
  adoptedSessionIdRef,
227
227
  optimisticUserIdRef,
228
228
  pendingUserDisplaysRef,
229
- setSessionModel,
229
+ seedSession,
230
230
  pendingComposerModelRef,
231
231
  pendingThinkingLevelRef,
232
232
  composerModelRef,
@@ -236,21 +236,11 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
236
236
  setSearchParams,
237
237
  });
238
238
 
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]);
239
+ // Queue auto-delivery moved server-side: the wrapper's terminal `agent_end`
240
+ // schedules the next queued prompt (`lib/queue/delivery.server.ts`), with a
241
+ // transactional head claim so no reload/tab race can double-send or wipe the
242
+ // table. The client panel is a view; the mount nudge lives in the queue
243
+ // hook. `stopHoldRef` remains only for the mock path below.
254
244
 
255
245
  const {
256
246
  handleSend,
@@ -262,7 +252,6 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
262
252
  stopGenerating,
263
253
  handleThinkingLevelChange,
264
254
  handleModelChange,
265
- closeExtensionDialog,
266
255
  } = useChatTimelineActions({
267
256
  inputValue,
268
257
  setInputValue,
@@ -272,7 +261,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
272
261
  sessionId,
273
262
  appSettings,
274
263
  messageQueue,
275
- setMessageQueue,
264
+ enqueueMessage,
265
+ removeMessage,
276
266
  executeSend,
277
267
  steerOmpAgent,
278
268
  ompAgent,
@@ -286,7 +276,6 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
286
276
  composerModelRef,
287
277
  accessModeRef,
288
278
  setSearchParams,
289
- dismissExtensionDialog,
290
279
  });
291
280
 
292
281
  return {
@@ -305,7 +294,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
305
294
  loadOlder,
306
295
  generatingVerb,
307
296
  messageQueue,
308
- setMessageQueue,
297
+ removeMessage,
298
+ reorderMessages,
309
299
  steeringQueue,
310
300
  setSteeringQueue,
311
301
  inputValue,
@@ -331,8 +321,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
331
321
  accessMode,
332
322
  handleAccessModeChange,
333
323
  composerModelRef,
334
- extensionDialog,
335
- closeExtensionDialog,
324
+ extensionDialogs,
325
+ resolveExtensionDialog,
336
326
  respondToExtensionUi: ompAgent.respondToExtensionUi,
337
327
  };
338
328
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Prompt assembly for the chamber composer: rewrite composer-level mentions
8
+ * into what oh-my-pi actually receives, then inline the text attachments the
9
+ * user dropped in. Extracted from the send path so that hook stays under the
10
+ * repo's per-file size ceiling.
11
+ */
12
+
13
+ import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
14
+ import { loadAgentNames } from '@/shared/lib/chat/composer/client';
15
+ import { translateAgentMentions, translateFileMentions } from '@/shared/lib/chat/composer/translate';
16
+
17
+ type TextFileAttachment = Parameters<typeof composeMessageWithTextAttachments>[1][number];
18
+
19
+ /**
20
+ * Build the outgoing prompt. `@agent` mentions are rewritten into an explicit
21
+ * task-tool delegation directive at send time (oh-my-pi has no `@agent`
22
+ * syntax); translation failures fall back to the raw prompt.
23
+ */
24
+ export async function buildPromptText(text: string, textFiles: TextFileAttachment[]): Promise<string> {
25
+ let translated = text;
26
+ try {
27
+ const names = await loadAgentNames();
28
+ if (names.length > 0) translated = translateAgentMentions(text, names).text;
29
+ } catch {
30
+ // keep the raw prompt
31
+ }
32
+ // File mentions are namespaced (`@file:`) by the picker; strip the namespace
33
+ // only after the agent pass so `@file:<name>` is never mistaken for `@agent`.
34
+ return composeMessageWithTextAttachments(translateFileMentions(translated), textFiles);
35
+ }
@@ -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
  }
@@ -15,33 +15,13 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
15
15
  import type { Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel } from '@/shared/types';
16
16
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
17
17
  import { streamChatResponse } from '@/client/hooks/chat/stream';
18
- import { composeMessageWithTextAttachments, isTextAttachmentFile } from '@/shared/lib/chat/attachments';
19
- import { loadAgentNames } from '@/shared/lib/chat/composer/client';
20
- import { translateAgentMentions, translateFileMentions } from '@/shared/lib/chat/composer/translate';
18
+ import type { SessionSeed } from '@/client/hooks/chat/timeline/session-load';
19
+ import { buildPromptText } from '@/client/hooks/chat/timeline/prompt-text';
20
+ import { isTextAttachmentFile } from '@/shared/lib/chat/attachments';
21
21
  import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
22
22
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
23
23
  import { formatClock } from '@/shared/lib/format/time';
24
24
 
25
- type TextFileAttachment = Parameters<typeof composeMessageWithTextAttachments>[1][number];
26
-
27
- /**
28
- * Build the outgoing prompt. `@agent` mentions are rewritten into an explicit
29
- * task-tool delegation directive at send time (oh-my-pi has no `@agent`
30
- * syntax); translation failures fall back to the raw prompt.
31
- */
32
- async function buildPromptText(text: string, textFiles: TextFileAttachment[]): Promise<string> {
33
- let translated = text;
34
- try {
35
- const names = await loadAgentNames();
36
- if (names.length > 0) translated = translateAgentMentions(text, names).text;
37
- } catch {
38
- // keep the raw prompt
39
- }
40
- // File mentions are namespaced (`@file:`) by the picker; strip the namespace
41
- // only after the agent pass so `@file:<name>` is never mistaken for `@agent`.
42
- return composeMessageWithTextAttachments(translateFileMentions(translated), textFiles);
43
- }
44
-
45
25
  export interface ChatTimelineSendDeps {
46
26
  folders: any[];
47
27
  selectedFolderId: number | null;
@@ -61,7 +41,9 @@ export interface ChatTimelineSendDeps {
61
41
  adoptedSessionIdRef: { current: string | null };
62
42
  optimisticUserIdRef: { current: string | null };
63
43
  pendingUserDisplaysRef: { current: { sent: string; display: string }[] };
64
- setSessionModel: (model: { provider: string; modelId: string } | null) => void;
44
+ /** Adopt the spawned session's identity (model + the thinking level the first
45
+ * prompt runs with) so the composer replacing the pending view shows it. */
46
+ seedSession: (seed: SessionSeed) => void;
65
47
  /** Model/thinking picked in the composer before the session existed —
66
48
  * applied to the spawn command so the first prompt runs with them. */
67
49
  pendingComposerModelRef: { current: { provider: string; modelId: string } | null };
@@ -106,7 +88,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
106
88
  adoptedSessionIdRef,
107
89
  optimisticUserIdRef,
108
90
  pendingUserDisplaysRef,
109
- setSessionModel,
91
+ seedSession,
110
92
  pendingComposerModelRef,
111
93
  pendingThinkingLevelRef,
112
94
  accessModeRef,
@@ -291,7 +273,11 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
291
273
  if (spawned) {
292
274
  adoptedSessionIdRef.current = spawned.sessionId;
293
275
  aiPlaceholderIdRef.current = aiPlaceholderId;
294
- if (spawned.model) setSessionModel(spawned.model);
276
+ // The spawn already applied this level to the session; seed it so the
277
+ // composer taking over from the pending view reports the run's real
278
+ // setting instead of a catalog default (the JSONL that carries it is
279
+ // not locatable yet).
280
+ seedSession({ model: spawned.model, thinkingLevel: composerThinking });
295
281
  pendingComposerModelRef.current = null;
296
282
  pendingThinkingLevelRef.current = null;
297
283
  fetch(`/api/chat/${encodeURIComponent(spawned.sessionId)}`, {
@@ -343,6 +329,6 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
343
329
  scrollToBottom,
344
330
  })
345
331
  );
346
- }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, setSessionModel, pendingComposerModelRef, pendingThinkingLevelRef, accessModeRef]);
332
+ }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, accessModeRef]);
347
333
  return { steerOmpAgent, executeSend };
348
334
  }