ompchamber 3.3.3 → 3.5.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 (73) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +315 -308
  3. package/dist/client/static/js/index-vx0ernrv.css +1 -0
  4. package/dist/client/static/js/{index-8ejde0b7.js → index-w0cem7n5.js} +641 -641
  5. package/package.json +1 -1
  6. package/src/client/components/common/session-actions-menu/index.tsx +201 -0
  7. package/src/client/components/common/session-delete-modal/index.tsx +128 -0
  8. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
  9. package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
  10. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
  11. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
  12. package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
  13. package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
  14. package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
  15. package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
  16. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
  17. package/src/client/components/workspace/btw-panel/index.tsx +20 -27
  18. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
  19. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
  20. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
  21. package/src/client/hooks/chat/btw/index.ts +7 -73
  22. package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
  23. package/src/client/hooks/chat/btw/intercept.ts +5 -2
  24. package/src/client/hooks/chat/btw/mode.ts +16 -76
  25. package/src/client/hooks/chat/timeline/actions.ts +22 -30
  26. package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
  27. package/src/client/hooks/chat/timeline/send.ts +8 -1
  28. package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
  29. package/src/client/hooks/workspace/session-delete.ts +94 -0
  30. package/src/server/lib/btw/child.server.test.ts +68 -0
  31. package/src/server/lib/btw/child.server.ts +17 -16
  32. package/src/server/lib/btw/frames.test.ts +3 -3
  33. package/src/server/lib/btw/frames.ts +1 -1
  34. package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
  35. package/src/server/lib/btw/lifecycle.server.ts +41 -31
  36. package/src/server/lib/btw/prompt.ts +7 -11
  37. package/src/server/lib/btw/purge.server.ts +56 -0
  38. package/src/server/lib/btw/registry.server.ts +1 -9
  39. package/src/server/lib/btw/reply.ts +54 -0
  40. package/src/server/lib/btw/runtime.server.test.ts +128 -0
  41. package/src/server/lib/btw/runtime.server.ts +77 -86
  42. package/src/server/lib/btw/schema.server.test.ts +84 -0
  43. package/src/server/lib/btw/schema.server.ts +8 -0
  44. package/src/server/lib/btw/service.server.ts +47 -78
  45. package/src/server/lib/btw/store.server.ts +9 -20
  46. package/src/server/lib/fs/path-segment.test.ts +100 -0
  47. package/src/server/lib/fs/path-segment.ts +45 -0
  48. package/src/server/lib/omp/rpc/constants.ts +10 -0
  49. package/src/server/lib/omp/rpc/session-commands.ts +45 -0
  50. package/src/server/lib/omp/session/delete.server.ts +136 -0
  51. package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
  52. package/src/server/lib/omp/session/merge-stored.ts +70 -1
  53. package/src/server/lib/omp/session/messages.ts +27 -2
  54. package/src/server/lib/omp/session/reader.ts +13 -0
  55. package/src/server/routes/agent/command.ts +9 -1
  56. package/src/server/routes/btw/topics.ts +2 -59
  57. package/src/server/routes/sessions/delete.test.ts +84 -0
  58. package/src/server/routes/sessions/delete.ts +83 -0
  59. package/src/server/routes/sessions/index.ts +5 -0
  60. package/src/shared/lib/chat/btw/client.ts +0 -30
  61. package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
  62. package/src/shared/lib/chat/composer/tui-only.ts +138 -0
  63. package/src/shared/lib/chat/notice-text.ts +33 -0
  64. package/src/shared/lib/chat/omp/agent-events.ts +18 -37
  65. package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
  66. package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
  67. package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
  68. package/src/shared/lib/chat/timeline/command-output.ts +65 -0
  69. package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
  70. package/src/shared/lib/omp/session/messages-map.ts +6 -1
  71. package/src/shared/lib/workspace/session-state/store.ts +20 -0
  72. package/src/shared/types/btw.ts +13 -34
  73. package/dist/client/static/js/index-bsxxt89j.css +0 -1
@@ -12,11 +12,14 @@
12
12
  * merge logic of its own and a reload can never disagree with the server.
13
13
  * The stream is attached only while the panel is open: a side question is a
14
14
  * deliberate action, not background traffic.
15
+ *
16
+ * There are no per-topic pickers here. A side question runs without tools and on
17
+ * the CHAT's model and thinking selector (the server re-reads them from the
18
+ * parent transcript at every ask), which is what omp's own `/btw` does.
15
19
  */
16
20
 
17
21
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
18
22
  import type { AgentImage, Attachment, BtwState, ChatMessageData, StreamTransport } from '@/shared/types';
19
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
20
23
  import { attachmentImage, readTextAttachments } from '@/shared/lib/chat/attachments';
21
24
  import {
22
25
  BtwRequestError,
@@ -26,10 +29,6 @@ import {
26
29
  deleteBtwTopic,
27
30
  fetchBtwState,
28
31
  promoteBtwTopic,
29
- respondBtwDialog,
30
- setBtwAccessMode,
31
- setBtwModel,
32
- setBtwThinkingLevel,
33
32
  } from '@/shared/lib/chat/btw/client';
34
33
  import type { StreamConnection } from '@/shared/lib/chat/omp/transport';
35
34
 
@@ -40,7 +39,7 @@ export type BtwImage = AgentImage;
40
39
  * The turn streaming right now. `messages` is the turn's conversation in the
41
40
  * chat's own shape, upserted by `message.id` exactly as the chat stream does —
42
41
  * so the panel renders a live side answer through the same components the chat
43
- * uses, tool calls included.
42
+ * uses.
44
43
  */
45
44
  export interface BtwLiveAnswer {
46
45
  topicId: string;
@@ -50,17 +49,6 @@ export interface BtwLiveAnswer {
50
49
  activity: string;
51
50
  }
52
51
 
53
- /**
54
- * Composer picks for a topic's FIRST question. A topic that does not exist yet
55
- * has no row to write them to, so they ride the ask and reach the spawn; later
56
- * questions address an existing topic through the dedicated setters.
57
- */
58
- export interface BtwFirstQuestionPicks {
59
- model?: { provider: string; id: string };
60
- thinkingLevel?: string;
61
- approvalMode?: ApprovalMode;
62
- }
63
-
64
52
  export interface BtwSessionHandle {
65
53
  state: BtwState | null;
66
54
  live: BtwLiveAnswer | null;
@@ -69,12 +57,8 @@ export interface BtwSessionHandle {
69
57
  busy: boolean;
70
58
  /** Resolves with the canonical state the command produced, or null when it
71
59
  * was refused (the reason is then in `error`). */
72
- ask: (question: string, attachments?: Attachment[], topicId?: string, picks?: BtwFirstQuestionPicks) => Promise<BtwState | null>;
60
+ ask: (question: string, attachments?: Attachment[], topicId?: string) => Promise<BtwState | null>;
73
61
  abort: (topicId: string) => Promise<void>;
74
- setModel: (topicId: string, provider: string, modelId: string) => Promise<void>;
75
- setThinkingLevel: (topicId: string, level: string) => Promise<void>;
76
- setAccessMode: (topicId: string, mode: ApprovalMode) => Promise<void>;
77
- respondToDialog: (topicId: string, id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => Promise<void>;
78
62
  /** Returns the new session id to open, or null when it was refused. */
79
63
  promote: (topicId: string) => Promise<string | null>;
80
64
  remove: (topicId: string) => Promise<void>;
@@ -186,7 +170,6 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
186
170
  question: string,
187
171
  attachments: Attachment[] = [],
188
172
  topicId?: string,
189
- picks?: BtwFirstQuestionPicks,
190
173
  ): Promise<BtwState | null> => {
191
174
  setError(null);
192
175
  // Same split the chat send path makes: images travel as provider payloads,
@@ -201,9 +184,6 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
201
184
  topicId,
202
185
  ...(images.length ? { images } : {}),
203
186
  ...(textFiles.length ? { textFiles } : {}),
204
- // Only a first question carries picks; a follow-up addresses a topic
205
- // that already owns them.
206
- ...(topicId ? {} : picks ?? {}),
207
187
  }));
208
188
  setLive(null);
209
189
  if (next && !next.runningTopicId) {
@@ -222,38 +202,6 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
222
202
  [run],
223
203
  );
224
204
 
225
- const setModel = useCallback(
226
- async (topicId: string, provider: string, modelId: string) => {
227
- setError(null);
228
- await run((id) => setBtwModel(id, topicId, provider, modelId));
229
- },
230
- [run],
231
- );
232
-
233
- const setThinkingLevel = useCallback(
234
- async (topicId: string, level: string) => {
235
- setError(null);
236
- await run((id) => setBtwThinkingLevel(id, topicId, level));
237
- },
238
- [run],
239
- );
240
-
241
- const setAccessMode = useCallback(
242
- async (topicId: string, mode: ApprovalMode) => {
243
- setError(null);
244
- await run((id) => setBtwAccessMode(id, topicId, mode));
245
- },
246
- [run],
247
- );
248
-
249
- const respondToDialog = useCallback(
250
- async (topicId: string, id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => {
251
- setError(null);
252
- await run((requestId) => respondBtwDialog(requestId, topicId, id, response));
253
- },
254
- [run],
255
- );
256
-
257
205
  const remove = useCallback(
258
206
  async (topicId: string) => {
259
207
  setError(null);
@@ -283,19 +231,5 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
283
231
 
284
232
  const clearError = useCallback(() => setError(null), []);
285
233
 
286
- return {
287
- state,
288
- live,
289
- error,
290
- busy,
291
- ask,
292
- abort,
293
- setModel,
294
- setThinkingLevel,
295
- setAccessMode,
296
- respondToDialog,
297
- promote,
298
- remove,
299
- clearError,
300
- };
234
+ return { state, live, error, busy, ask, abort, promote, remove, clearError };
301
235
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `/btw` interception.
8
+ *
9
+ * The token must be recognized the way omp parses a slash invocation — on the
10
+ * earliest whitespace OR `:` — because a form the chamber does not intercept is
11
+ * sent to the model as literal text (omp's `/btw` is TUI-only, so nothing
12
+ * downstream understands it).
13
+ */
14
+
15
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
16
+ import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
17
+
18
+ /**
19
+ * `bun test` runs without a DOM; the interceptor only needs an event target.
20
+ *
21
+ * The stub is installed for this file alone and REMOVED afterwards: several
22
+ * other files in this suite install their own DOM by assigning `globalThis`,
23
+ * and a `window` left behind here replaced theirs (measured: `use-file-editor`
24
+ * failed with `form.get is not a function`). The descriptor is `writable` for
25
+ * the same reason — those files assign rather than define.
26
+ */
27
+ const hadWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
28
+
29
+ beforeAll(() => {
30
+ Object.defineProperty(globalThis, 'window', { value: new EventTarget(), configurable: true, writable: true });
31
+ });
32
+
33
+ afterAll(() => {
34
+ if (hadWindow) Object.defineProperty(globalThis, 'window', hadWindow);
35
+ else delete (globalThis as Record<string, unknown>).window;
36
+ });
37
+
38
+ function capture(text: string): { handled: boolean; detail: unknown } {
39
+ let detail: unknown;
40
+ const listener = (event: Event) => {
41
+ detail = (event as CustomEvent).detail;
42
+ };
43
+ window.addEventListener('omp:btw', listener);
44
+ try {
45
+ const handled = dispatchBtwCommand(text, []);
46
+ return { handled, detail };
47
+ } finally {
48
+ window.removeEventListener('omp:btw', listener);
49
+ }
50
+ }
51
+
52
+ describe('dispatchBtwCommand', () => {
53
+ test('a bare command opens the panel with no question', () => {
54
+ const { handled, detail } = capture('/btw');
55
+ expect(handled).toBe(true);
56
+ expect(detail).toEqual({ question: '' });
57
+ });
58
+
59
+ test('a whitespace-separated question is carried', () => {
60
+ const { handled, detail } = capture('/btw what is this?');
61
+ expect(handled).toBe(true);
62
+ expect(detail).toEqual({ question: 'what is this?' });
63
+ });
64
+
65
+ test('a colon-separated question is the same invocation', () => {
66
+ const { handled, detail } = capture('/btw:what is this?');
67
+ expect(handled).toBe(true);
68
+ expect(detail).toEqual({ question: 'what is this?' });
69
+ });
70
+
71
+ test('case does not matter', () => {
72
+ expect(capture('/BTW hi').handled).toBe(true);
73
+ });
74
+
75
+ test('anything else is left for the normal send path', () => {
76
+ expect(capture('hello /btw').handled).toBe(false);
77
+ expect(capture('/btwx').handled).toBe(false);
78
+ expect(capture('').handled).toBe(false);
79
+ });
80
+ });
@@ -23,8 +23,11 @@
23
23
  import type { Attachment } from '@/shared/types';
24
24
 
25
25
  /** A bare `/btw` opens the form on the current history; anything after it is
26
- * the first question. Case-insensitive, because omp's command tokens are. */
27
- const BTW_COMMAND_RE = /^\/btw(?:\s+([\s\S]*))?$/i;
26
+ * the first question. Case-insensitive, because omp's command tokens are, and
27
+ * the separator is whitespace OR `:` — `parseSlashCommand` splits on whichever
28
+ * comes first, so `/btw:what is this` is the same invocation as `/btw what is
29
+ * this` and must not be sent to the model as text. */
30
+ const BTW_COMMAND_RE = /^\/btw(?:[\s:]+([\s\S]*))?$/i;
28
31
 
29
32
  /**
30
33
  * Hand a `/btw` draft to the side-question panel. Returns false when the text
@@ -14,28 +14,24 @@
14
14
  *
15
15
  * `/btw [question]` in the main composer is the entry point; a question that
16
16
  * arrived with the command is asked immediately, which is what the TUI does.
17
+ *
18
+ * The panel has no model / thinking / access pickers: a side question runs on
19
+ * the chat's own model and thinking selector and without tools, so a control
20
+ * here would promise something the child does not read.
17
21
  */
18
22
 
19
23
  import { useCallback, useState } from 'preact/hooks';
20
- import type { Attachment, BtwDialog, BtwState, BtwTopic, ChatMessageData } from '@/shared/types';
21
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
22
- import { useBtwSession, type BtwFirstQuestionPicks, type BtwLiveAnswer } from '@/client/hooks/chat/btw';
24
+ import type { Attachment, BtwState, BtwTopic, ChatMessageData } from '@/shared/types';
25
+ import { useBtwSession, type BtwLiveAnswer } from '@/client/hooks/chat/btw';
23
26
  import { useChamberEvent } from '@/client/hooks/ui/window-event';
24
27
  import { useSessionState } from '@/client/hooks/workspace/session-state';
25
28
  import { useSearchParams } from '@/client/lib/router/search-params';
26
29
  import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
27
- import { normalizeApprovalMode } from '@/shared/lib/omp/config/access-mode';
28
30
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
29
- import { readChamberSetting } from '@/shared/lib/settings/client';
30
31
 
31
32
  /** Question sent when a question arrived carrying only images. */
32
33
  const ATTACHMENT_ONLY_QUESTION = 'Describe the attached image.';
33
34
 
34
- /** The composer's persisted access pick, for a topic that has none yet. */
35
- function readAccessMode(appSettings: Record<string, unknown>): ApprovalMode {
36
- return normalizeApprovalMode(readChamberSetting<unknown>('omp_access_mode', appSettings));
37
- }
38
-
39
35
  export interface BtwMode {
40
36
  open: boolean;
41
37
  topics: BtwTopic[];
@@ -49,11 +45,6 @@ export interface BtwMode {
49
45
  /** What that turn is doing, for the panel's indicator (side stream's own). */
50
46
  liveVerb?: string;
51
47
  error: string | null;
52
- /** Ask/approval dialogs the side child is blocked on, oldest first. */
53
- dialogs: BtwDialog[];
54
- /** Access mode the active topic's child runs under. */
55
- accessMode: ApprovalMode;
56
- thinkingLevel: string;
57
48
  askDraft: string;
58
49
  followUpDraft: string;
59
50
  setAskDraft: (value: string) => void;
@@ -68,12 +59,8 @@ export interface BtwMode {
68
59
  submitAsk: () => void;
69
60
  /** Submit the side session's composer: a follow-up to the active topic. */
70
61
  submitFollowUp: (question: string, attachments?: Attachment[]) => void;
71
- abort: () => void;
72
- /** Re-target the side child's model (applies to the running turn). */
73
- setModel: (provider: string, modelId: string) => void;
74
- setThinkingLevel: (level: string) => void;
75
- setAccessMode: (mode: ApprovalMode) => void;
76
- respondToDialog: (id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => void;
62
+ /** Cancel the running turn, if any. Returns whether one was running. */
63
+ abort: () => boolean;
77
64
  /** Returns the session id to open, or null when promotion was refused. */
78
65
  promote: () => Promise<string | null>;
79
66
  removeTopic: (topicId: string) => void;
@@ -121,16 +108,6 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
121
108
  */
122
109
  const liveVerb = live?.activity || (runningTopicId ? PHASE_VERBS.sideQuestion : undefined);
123
110
 
124
- /**
125
- * Composer picks made before a topic exists. They are per-session UI state
126
- * rather than props, because the form must show what the FIRST question will
127
- * actually run with — the topic that would otherwise own these values has not
128
- * been created yet.
129
- */
130
- const [pendingModel, setPendingModel] = useSessionState<BtwFirstQuestionPicks['model'] | null>('chat.btwModel', null);
131
- const [pendingThinking, setPendingThinking] = useSessionState<string>('chat.btwThinking', 'auto');
132
- const [pendingAccess, setPendingAccess] = useSessionState<ApprovalMode | null>('chat.btwAccess', null);
133
-
134
111
  const selectNewest = useCallback(
135
112
  (next: BtwState | null) => {
136
113
  if (!next) return;
@@ -151,27 +128,12 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
151
128
  }
152
129
  });
153
130
 
154
- /**
155
- * What the next FIRST question will run with: the composer's picks until a
156
- * topic exists, then that topic's own settings. Every setter below writes to
157
- * the topic when there is one and to this pending slot when there is not —
158
- * a pick that only reached the UI would silently not apply.
159
- */
160
- const firstQuestionPicks = useCallback(
161
- (): BtwFirstQuestionPicks => ({
162
- ...(pendingModel ? { model: pendingModel } : {}),
163
- ...(pendingThinking && pendingThinking !== 'auto' ? { thinkingLevel: pendingThinking } : {}),
164
- ...(pendingAccess ? { approvalMode: pendingAccess } : {}),
165
- }),
166
- [pendingModel, pendingThinking, pendingAccess],
167
- );
168
-
169
131
  const submitAsk = useCallback(() => {
170
132
  const question = askDraft.trim();
171
133
  if (!question) return;
172
134
  setAskDraft('');
173
- void session.ask(question, undefined, undefined, firstQuestionPicks()).then(selectNewest);
174
- }, [askDraft, session, setAskDraft, selectNewest, firstQuestionPicks]);
135
+ void session.ask(question).then(selectNewest);
136
+ }, [askDraft, session, setAskDraft, selectNewest]);
175
137
 
176
138
  const submitFollowUp = useCallback(
177
139
  (question: string, attachments?: Attachment[]) => {
@@ -220,12 +182,6 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
220
182
  liveMessages: live ? live.messages : [],
221
183
  liveVerb,
222
184
  error: session.error,
223
- dialogs: session.state?.dialogs ?? [],
224
- // Before a topic exists the composer's own picks are what the next question
225
- // will run with, so they are what the controls must show — and after it,
226
- // the topic's own settings (which the pick was written to).
227
- accessMode: activeTopic?.approvalMode ?? pendingAccess ?? readAccessMode(appSettings),
228
- thinkingLevel: activeTopic?.thinkingLevel ?? pendingThinking,
229
185
  askDraft,
230
186
  followUpDraft,
231
187
  setAskDraft,
@@ -239,29 +195,13 @@ export function useBtwMode(sessionId: string | null, appSettings: Record<string,
239
195
  },
240
196
  submitAsk,
241
197
  submitFollowUp,
198
+ // Escape (and the composer's Stop) cancel the turn BEFORE leaving: omp's
199
+ // Escape handler cancels a running answer and only then closes the panel, so
200
+ // a stray keypress cannot abandon a question mid-flight.
242
201
  abort: () => {
243
- if (runningTopicId) void session.abort(runningTopicId);
244
- },
245
- setModel: (provider: string, modelId: string) => {
246
- if (!activeTopic) {
247
- setPendingModel({ provider, id: modelId });
248
- return;
249
- }
250
- void session.setModel(activeTopic.id, provider, modelId);
251
- },
252
- setThinkingLevel: (level: string) => {
253
- setPendingThinking(level);
254
- if (!activeTopic) return;
255
- void session.setThinkingLevel(activeTopic.id, level);
256
- },
257
- setAccessMode: (mode: ApprovalMode) => {
258
- setPendingAccess(mode);
259
- if (!activeTopic) return;
260
- void session.setAccessMode(activeTopic.id, mode);
261
- },
262
- respondToDialog: (id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => {
263
- if (!activeTopic) return;
264
- void session.respondToDialog(activeTopic.id, id, response);
202
+ if (!runningTopicId) return false;
203
+ void session.abort(runningTopicId);
204
+ return true;
265
205
  },
266
206
  promote,
267
207
  removeTopic,
@@ -11,13 +11,15 @@
11
11
  * record — captured semantics are unchanged.
12
12
  */
13
13
 
14
- import { useCallback } from 'preact/hooks';
14
+ import { useCallback, useMemo } from 'preact/hooks';
15
15
  import type { Dispatch, SetStateAction } from 'preact/compat';
16
16
  import type { Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel } from '@/shared/types';
17
17
  import type { QueuedMessage } from '@/client/components/workspace/chat-timeline/QueueList';
18
18
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
19
  import { applyComposerPick, consumeComposerPick, stashComposerPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
20
20
  import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
21
+ import { blockTuiOnlySend } from '@/client/hooks/chat/timeline/tui-only-guard';
22
+ import { createQueueActions } from '@/client/hooks/chat/timeline/queue-actions';
21
23
  import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
22
24
 
23
25
  export interface ChatTimelineActionsDeps {
@@ -76,14 +78,12 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
76
78
  const {
77
79
  inputValue,
78
80
  setInputValue,
79
- setInputAttachments,
80
81
  isGenerating,
81
82
  isOmpSession,
82
83
  sessionId,
83
84
  appSettings,
84
85
  messageQueue,
85
86
  enqueueMessage,
86
- removeMessage,
87
87
  executeSend,
88
88
  steerOmpAgent,
89
89
  ompAgent,
@@ -100,6 +100,10 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
100
100
  setSearchParams,
101
101
  } = deps;
102
102
 
103
+ // The queue's two row actions take the same slice of this deps record, so
104
+ // they are built once per render from it rather than re-listing every field.
105
+ const queueActions = useMemo(() => createQueueActions(deps), [deps]);
106
+
103
107
  const handleSend = useCallback(async (attachments: Attachment[], options?: { steering?: boolean }) => {
104
108
  const textToSend = inputValue.trim();
105
109
  if (!textToSend && attachments.length === 0) return;
@@ -111,11 +115,21 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
111
115
  // `/btw [question]` is the side-question entry point, not chat text: the
112
116
  // panel owns it (omp's `/btw` is TUI-only, so nothing downstream would
113
117
  // understand the token). A bare `/btw` only opens the panel's history.
118
+ //
119
+ // MUST run before the TUI-only guard below: `/btw` is in that table (omp
120
+ // implements it in the TUI only), so the guard would refuse the very
121
+ // command the chamber answers with a panel.
114
122
  if (dispatchBtwCommand(textToSend, attachments)) {
115
123
  setInputValue('');
116
124
  return;
117
125
  }
118
126
 
127
+ // A command omp only implements in its TUI (`/plan`, `/clear`, `/login`, …)
128
+ // would reach the model as literal text over RPC and burn a whole turn on
129
+ // an improvisation. Answer it here, before it is queued or steered, and
130
+ // keep the draft so the user can edit or retype it.
131
+ if (blockTuiOnlySend(textToSend, setLocalMessages)) return;
132
+
119
133
  if (isGenerating) {
120
134
  if (options?.steering) {
121
135
  // Explicit steering while a run is active.
@@ -161,37 +175,15 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
161
175
 
162
176
  setInputValue('');
163
177
  executeSend(textToSend, attachments);
164
- }, [inputValue, isGenerating, executeSend, enqueueMessage, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef]);
178
+ }, [inputValue, isGenerating, executeSend, enqueueMessage, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef, setLocalMessages]);
165
179
 
166
180
  const handleEditQueueItem = useCallback((item: QueuedMessage) => {
167
- // Lift the text into the composer and drop the row. If the user never
168
- // re-submits, the delete (below) already removed it — the old flow had a
169
- // dead window here where removing from the local list was the only edit.
170
- setInputValue(item.text);
171
- setInputAttachments(item.attachments);
172
- removeMessage(item.id);
173
- }, [removeMessage, setInputValue, setInputAttachments]);
181
+ queueActions.handleEditQueueItem(item);
182
+ }, [queueActions]);
174
183
 
175
184
  const handleSendNowQueueItem = useCallback(async (item: QueuedMessage) => {
176
- // Explicit delivery also lifts the Stop hold (mock path).
177
- stopHoldRef.current = false;
178
- removeMessage(item.id);
179
-
180
- if (isGenerating) {
181
- if (isOmpSession) {
182
- void steerOmpAgent(item.text, item.attachments);
183
- } else {
184
- if (abortControllerRef.current) {
185
- abortControllerRef.current.abort();
186
- abortControllerRef.current = null;
187
- }
188
- setGenerating(false);
189
- setTimeout(() => executeSend(item.text, item.attachments, { model: item.model }), 0);
190
- }
191
- } else {
192
- executeSend(item.text, item.attachments, { model: item.model });
193
- }
194
- }, [isGenerating, executeSend, removeMessage, isOmpSession, steerOmpAgent, abortControllerRef, setGenerating, stopHoldRef]);
185
+ await queueActions.handleSendNowQueueItem(item);
186
+ }, [queueActions]);
195
187
 
196
188
  const handleUndo = useCallback(async (msgId: string, content?: string): Promise<boolean> => {
197
189
  if (isGenerating) {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Queue-row actions for the chat timeline: editing a queued item back into the
8
+ * composer, and delivering one immediately.
9
+ *
10
+ * Split out of `actions.ts` so that hook keeps to the repo's per-file ceiling.
11
+ * Both handlers take the same deps record, and both are about ONE row of the
12
+ * follow-up queue rather than about the composer's own send path — which is the
13
+ * seam this file draws.
14
+ */
15
+
16
+ import type { Dispatch, SetStateAction } from 'preact/compat';
17
+ import type { Attachment, QueuedMessageModel } from '@/shared/types';
18
+ import type { QueuedMessage } from '@/client/components/workspace/chat-timeline/QueueList';
19
+
20
+ export interface QueueActionsDeps {
21
+ setInputValue: (v: string) => void;
22
+ setInputAttachments: Dispatch<SetStateAction<Attachment[]>>;
23
+ removeMessage: (id: string) => void;
24
+ /** Armed by Stop; the queue auto-process holds off while it is set. */
25
+ stopHoldRef: { current: boolean };
26
+ isGenerating: boolean;
27
+ isOmpSession: boolean;
28
+ steerOmpAgent: (text: string, attachments: Attachment[]) => Promise<void>;
29
+ abortControllerRef: { current: AbortController | null };
30
+ setGenerating: (v: boolean) => void;
31
+ executeSend: (
32
+ text: string,
33
+ attachments: Attachment[],
34
+ options?: { model?: QueuedMessageModel | null },
35
+ ) => Promise<void>;
36
+ }
37
+
38
+ export interface QueueActions {
39
+ handleEditQueueItem: (item: QueuedMessage) => void;
40
+ handleSendNowQueueItem: (item: QueuedMessage) => Promise<void>;
41
+ }
42
+
43
+ export function createQueueActions(deps: QueueActionsDeps): QueueActions {
44
+ const {
45
+ setInputValue,
46
+ setInputAttachments,
47
+ removeMessage,
48
+ stopHoldRef,
49
+ isGenerating,
50
+ isOmpSession,
51
+ steerOmpAgent,
52
+ abortControllerRef,
53
+ setGenerating,
54
+ executeSend,
55
+ } = deps;
56
+ return {
57
+ /** Lift the row's text into the composer and drop the row. If the user
58
+ * never re-submits, the delete already removed it — the old flow had a
59
+ * dead window here where removing from the local list was the only edit. */
60
+ handleEditQueueItem: (item) => {
61
+ setInputValue(item.text);
62
+ setInputAttachments(item.attachments);
63
+ removeMessage(item.id);
64
+ },
65
+
66
+ /** Deliver one row now, ahead of the queue. Explicit delivery also lifts
67
+ * the Stop hold, so the auto-process may resume after this run ends. */
68
+ handleSendNowQueueItem: async (item) => {
69
+ stopHoldRef.current = false;
70
+ removeMessage(item.id);
71
+
72
+ if (isGenerating) {
73
+ if (isOmpSession) {
74
+ void steerOmpAgent(item.text, item.attachments);
75
+ } else {
76
+ if (abortControllerRef.current) {
77
+ abortControllerRef.current.abort();
78
+ abortControllerRef.current = null;
79
+ }
80
+ setGenerating(false);
81
+ setTimeout(() => executeSend(item.text, item.attachments, { model: item.model }), 0);
82
+ }
83
+ } else {
84
+ executeSend(item.text, item.attachments, { model: item.model });
85
+ }
86
+ },
87
+ };
88
+ }
@@ -26,6 +26,7 @@ import {
26
26
  readTextAttachments,
27
27
  } from '@/shared/lib/chat/attachments';
28
28
  import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
29
+ import { blockTuiOnlySend } from '@/client/hooks/chat/timeline/tui-only-guard';
29
30
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
30
31
  import { formatClock } from '@/shared/lib/format/time';
31
32
 
@@ -135,6 +136,12 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
135
136
  attachments: Attachment[],
136
137
  options?: { model?: QueuedMessageModel | null },
137
138
  ) => {
139
+ // Last gate before a prompt leaves the client. `handleSend` already refuses
140
+ // these, but `executeSend` is also reached by a retry of a stored turn, a
141
+ // queued item's "Send now", and `submitNewChat` — all of which can carry a
142
+ // command that was typed before the guard existed, or that arrived from the
143
+ // queue. Nothing has been rendered yet, so no rollback is needed.
144
+ if (blockTuiOnlySend(text, setLocalMessages)) return;
138
145
  // A queued delivery replays the snapshot the item was queued with; a plain
139
146
  // send keeps the session's live picks. 'auto' thinking leaves omp alone.
140
147
  const modelOverride = options?.model ?? null;
@@ -304,6 +311,6 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
304
311
  scrollToBottom,
305
312
  })
306
313
  );
307
- }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, deferredComposerPickRef, accessModeRef]);
314
+ }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, deferredComposerPickRef, accessModeRef, setLocalMessages]);
308
315
  return { steerOmpAgent, executeSend };
309
316
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Refuse a slash command omp only implements in its own TUI, in the composer,
8
+ * before it can cost a turn.
9
+ *
10
+ * The guard exists because the RPC prompt path has no slash handling for these
11
+ * names: `session.prompt()` only expands file commands and prompt templates, so
12
+ * `/plan` arrives at the model as literal text and it improvises a whole turn
13
+ * around the string (measured: `/hotkeys` sent the model to read omp's own docs
14
+ * and run bash). The composer already knows the token, so it answers locally
15
+ * with the same notice row shape a real `command_output` frame produces — the
16
+ * user sees why nothing ran, and the model never sees the command.
17
+ *
18
+ * Every send path funnels through here: a fresh send, a steer, a retry of a
19
+ * stored turn, and a queued item's "send now". Queued auto-delivery is covered
20
+ * server-side (`blockedSlashPrompt` in the RPC dispatcher), because it never
21
+ * passes through this module.
22
+ */
23
+
24
+ import type { Dispatch, SetStateAction } from 'preact/compat';
25
+ import type { ChatMessageData } from '@/shared/types';
26
+ import { isTuiOnlySlashCommand, tuiOnlyCommandNotice } from '@/shared/lib/chat/composer/tui-only';
27
+
28
+ /**
29
+ * Append the refusal as a notice row, the same shape `onCommandOutput` builds.
30
+ * Placed at the tail: no AI placeholder exists yet, so there is nothing to
31
+ * insert before.
32
+ */
33
+ export function appendTuiOnlyNotice(
34
+ text: string,
35
+ setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>,
36
+ ): void {
37
+ const row: ChatMessageData = {
38
+ id: `tui-only-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
39
+ role: 'ai',
40
+ content: '',
41
+ notice: tuiOnlyCommandNotice(text),
42
+ };
43
+ setLocalMessages(prev => (prev.some(m => m.notice === row.notice) ? prev : [...prev, row]));
44
+ }
45
+
46
+ /**
47
+ * Whether the draft is a TUI-only command and was answered locally. A true
48
+ * result means the caller must stop — nothing was sent, and the draft is left
49
+ * in the composer so the user can edit or retype it.
50
+ */
51
+ export function blockTuiOnlySend(
52
+ text: string,
53
+ setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>,
54
+ ): boolean {
55
+ if (!isTuiOnlySlashCommand(text)) return false;
56
+ appendTuiOnlyNotice(text, setLocalMessages);
57
+ return true;
58
+ }