ompchamber 3.3.3 → 3.4.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 (55) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +275 -268
  3. package/dist/client/static/js/{index-8ejde0b7.js → index-h05z90mw.js} +581 -581
  4. package/package.json +1 -1
  5. package/src/client/components/settings/categories/AppearanceSettings.tsx +1 -1
  6. package/src/client/components/settings/categories/ChatSettings.tsx +1 -1
  7. package/src/client/components/workspace/btw-panel/AskCard.tsx +6 -1
  8. package/src/client/components/workspace/btw-panel/Messages.tsx +1 -1
  9. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +1 -1
  10. package/src/client/components/workspace/btw-panel/index.tsx +20 -27
  11. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +29 -18
  12. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +9 -39
  13. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +62 -0
  14. package/src/client/hooks/chat/btw/index.ts +7 -73
  15. package/src/client/hooks/chat/btw/intercept.test.ts +80 -0
  16. package/src/client/hooks/chat/btw/intercept.ts +5 -2
  17. package/src/client/hooks/chat/btw/mode.ts +16 -76
  18. package/src/client/hooks/chat/timeline/actions.ts +22 -30
  19. package/src/client/hooks/chat/timeline/queue-actions.ts +88 -0
  20. package/src/client/hooks/chat/timeline/send.ts +8 -1
  21. package/src/client/hooks/chat/timeline/tui-only-guard.ts +58 -0
  22. package/src/server/lib/btw/child.server.test.ts +68 -0
  23. package/src/server/lib/btw/child.server.ts +17 -16
  24. package/src/server/lib/btw/frames.test.ts +3 -3
  25. package/src/server/lib/btw/frames.ts +1 -1
  26. package/src/server/lib/btw/lifecycle.server.test.ts +139 -0
  27. package/src/server/lib/btw/lifecycle.server.ts +41 -31
  28. package/src/server/lib/btw/prompt.ts +7 -11
  29. package/src/server/lib/btw/registry.server.ts +1 -9
  30. package/src/server/lib/btw/reply.ts +54 -0
  31. package/src/server/lib/btw/runtime.server.test.ts +128 -0
  32. package/src/server/lib/btw/runtime.server.ts +77 -86
  33. package/src/server/lib/btw/schema.server.test.ts +84 -0
  34. package/src/server/lib/btw/schema.server.ts +8 -0
  35. package/src/server/lib/btw/service.server.ts +47 -78
  36. package/src/server/lib/btw/store.server.ts +9 -20
  37. package/src/server/lib/omp/rpc/constants.ts +10 -0
  38. package/src/server/lib/omp/rpc/session-commands.ts +45 -0
  39. package/src/server/lib/omp/session/merge-stored-notices.test.ts +81 -0
  40. package/src/server/lib/omp/session/merge-stored.ts +70 -1
  41. package/src/server/lib/omp/session/messages.ts +27 -2
  42. package/src/server/routes/agent/command.ts +9 -1
  43. package/src/server/routes/btw/topics.ts +2 -59
  44. package/src/shared/lib/chat/btw/client.ts +0 -30
  45. package/src/shared/lib/chat/composer/tui-only.test.ts +90 -0
  46. package/src/shared/lib/chat/composer/tui-only.ts +138 -0
  47. package/src/shared/lib/chat/notice-text.ts +33 -0
  48. package/src/shared/lib/chat/omp/agent-events.ts +18 -37
  49. package/src/shared/lib/chat/omp/available-commands-cache.test.ts +66 -0
  50. package/src/shared/lib/chat/omp/command-output-ansi.test.ts +84 -0
  51. package/src/shared/lib/chat/omp/fold-deps.ts +48 -0
  52. package/src/shared/lib/chat/timeline/command-output.ts +65 -0
  53. package/src/shared/lib/chat/timeline/omp-callbacks.ts +4 -17
  54. package/src/shared/lib/omp/session/messages-map.ts +6 -1
  55. package/src/shared/types/btw.ts +13 -34
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "3.3.3",
3
+ "version": "3.4.0",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -11,7 +11,7 @@ interface AppearanceSettingsProps {
11
11
 
12
12
  export function AppearanceSettings({ settings, onUpdate }: AppearanceSettingsProps) {
13
13
  return (
14
- <div className="w-full space-y-8 text-xs text-ink">
14
+ <div className="w-full space-y-8 text-xs text-ink pb-3">
15
15
  <section className="space-y-4">
16
16
  <div className="flex items-center space-x-2 border-b border-ink/10 pb-2">
17
17
  <Palette size={16} className="text-ink/60" />
@@ -11,7 +11,7 @@ interface ChatSettingsProps {
11
11
 
12
12
  export function ChatSettings({ settings, onUpdate }: ChatSettingsProps) {
13
13
  return (
14
- <div className="w-full space-y-7 text-xs text-ink">
14
+ <div className="w-full space-y-7 text-xs text-ink pb-3">
15
15
  {/* 1. Streaming Transport (WebSocket vs SSE) */}
16
16
  <ChatTransportSection settings={settings} onUpdate={onUpdate} />
17
17
 
@@ -31,6 +31,8 @@ export interface BtwAskCardProps {
31
31
  onNewQuestion: () => void;
32
32
  /** `✕` — leave the side-question form. */
33
33
  onClose: () => void;
34
+ /** Escape — cancel the running answer first. Returns whether one was running. */
35
+ onCancel: () => boolean;
34
36
  /** A side answer is streaming: starting over waits for it. */
35
37
  running: boolean;
36
38
  topics: BtwTopic[];
@@ -50,6 +52,7 @@ export function BtwAskCard({
50
52
  onSubmit,
51
53
  onNewQuestion,
52
54
  onClose,
55
+ onCancel,
53
56
  running,
54
57
  topics,
55
58
  activeTopicId,
@@ -72,7 +75,9 @@ export function BtwAskCard({
72
75
  const handleKeyDown = (event: TargetedKeyboardEvent<HTMLTextAreaElement>) => {
73
76
  if (event.key === 'Escape') {
74
77
  event.preventDefault();
75
- onClose();
78
+ // Cancel first, close only when nothing was running — omp's Escape handler
79
+ // does the same, so one keypress cannot abandon a question mid-flight.
80
+ if (!onCancel()) onClose();
76
81
  return;
77
82
  }
78
83
  if (event.key !== 'Enter' || event.shiftKey) return;
@@ -48,7 +48,7 @@ function AnswerStatus({ status }: { status: BtwTurnStatus }) {
48
48
  return <div className="text-[11px] text-ink/50">Cancelled</div>;
49
49
  }
50
50
 
51
- return <div className="text-[11px] text-error">{status === 'failed' ? 'Failed' : 'Interrupted'}</div>;
51
+ return <div className="text-[11px] text-error">{status === 'error' ? 'Error' : 'Interrupted'}</div>;
52
52
  }
53
53
 
54
54
  /** The question row for a turn, in the chat's own user-bubble shape. */
@@ -24,7 +24,7 @@ const STATUS_LABEL: Record<BtwTurnStatus, string> = {
24
24
  running: 'thinking…',
25
25
  complete: 'complete',
26
26
  cancelled: 'cancelled',
27
- failed: 'failed',
27
+ error: 'error',
28
28
  interrupted: 'interrupted',
29
29
  };
30
30
 
@@ -23,11 +23,12 @@
23
23
  * question — and promotion is the one explicit action that moves an answer into
24
24
  * the chat, as a session branched from it.
25
25
  *
26
- * The composer card is the CHAT's own `ChatInput`, not a look-alike: a side
27
- * question runs a child with tools and an access mode, so it offers the same
28
- * model / thinking / access controls, the same attachments, drop targets and
29
- * keybindings. Only the autocomplete is off (`enablePicker`) — a side question
30
- * has no file tree, commands or skills to complete against.
26
+ * The composer card is the CHAT's own `ChatInput`, not a look-alike: the same
27
+ * attachments, drop targets and keybindings. Only the autocomplete is off
28
+ * (`enablePicker`) — a side question has no file tree, commands or skills to
29
+ * complete against — and so are the model / thinking / access controls: the side
30
+ * child runs on the chat's model and thinking selector, without tools, so those
31
+ * dropdowns would change nothing. That is exactly what omp's own `/btw` offers.
31
32
  */
32
33
 
33
34
  import { useState } from 'preact/hooks';
@@ -37,7 +38,6 @@ import { BtwAskCard } from '@/client/components/workspace/btw-panel/AskCard';
37
38
  import { BtwMessages } from '@/client/components/workspace/btw-panel/Messages';
38
39
  import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input/index';
39
40
  import { GeneratingIndicator } from '@/client/components/workspace/chat-timeline/GeneratingIndicator';
40
- import { ExtensionDialog } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
41
41
 
42
42
  /** Tallest the answer card grows before its own scrollbar takes over. */
43
43
  const ANSWERS_MAX_HEIGHT_PX = 520;
@@ -70,9 +70,15 @@ export function BtwForm({
70
70
  // the same click burst would be a second request for work already running.
71
71
  // An already-promoted topic needs no second button either — its branch
72
72
  // session exists, and the server hands that id back on a repeat promote.
73
+ // A topic with follow-ups cannot be promoted (a branch carries one pair), so
74
+ // the action is offered only where the server would accept it.
73
75
  const canPromote =
74
- Boolean(mode.activeTopic) && !mode.runningTopicId && !mode.busy && !mode.activeTopic?.promotedSessionId && lastTurn?.status === 'complete';
75
- const dialog = mode.dialogs[0];
76
+ Boolean(mode.activeTopic) &&
77
+ mode.activeTopic!.turns.length === 1 &&
78
+ !mode.runningTopicId &&
79
+ !mode.busy &&
80
+ !mode.activeTopic?.promotedSessionId &&
81
+ lastTurn?.status === 'complete';
76
82
  const running = Boolean(mode.runningTopicId);
77
83
 
78
84
  return (
@@ -91,6 +97,7 @@ export function BtwForm({
91
97
  onSubmit={mode.submitAsk}
92
98
  onNewQuestion={mode.resetQuestion}
93
99
  onClose={mode.exit}
100
+ onCancel={mode.abort}
94
101
  running={running}
95
102
  topics={mode.topics}
96
103
  activeTopicId={mode.activeTopic?.id ?? null}
@@ -107,7 +114,7 @@ export function BtwForm({
107
114
  provider={mode.activeTopic?.model?.provider ?? provider}
108
115
  providerNames={providerNames}
109
116
  modelName={mode.activeTopic?.model?.name ?? modelName}
110
- thinkingLevel={mode.thinkingLevel}
117
+ thinkingLevel={mode.activeTopic?.thinkingLevel}
111
118
  isMobile={variant === 'mobile'}
112
119
  />
113
120
 
@@ -135,33 +142,19 @@ export function BtwForm({
135
142
  setAttachments([]);
136
143
  }}
137
144
  isGenerating={running}
138
- onStop={mode.abort}
145
+ onStop={() => void mode.abort()}
139
146
  appSettings={appSettings}
140
147
  attachments={attachments}
141
148
  onAttachmentsChange={setAttachments}
142
149
  rootPath={rootPath}
143
150
  variant={variant}
144
151
  enablePicker={false}
152
+ showModel={false}
153
+ showThinking={false}
154
+ showAccess={false}
145
155
  placeholder="Ask in this btw session..."
146
- accessMode={mode.accessMode}
147
- onAccessModeChange={mode.setAccessMode}
148
- onThinkingLevelChange={mode.setThinkingLevel}
149
- onModelChange={mode.setModel}
150
- sessionModel={mode.activeTopic?.model ? { provider: mode.activeTopic.model.provider, modelId: mode.activeTopic.model.id } : null}
151
- sessionThinkingLevel={mode.thinkingLevel}
152
156
  composerModelRef={composerModelRef}
153
157
  />
154
-
155
- {/* A side child runs with tools, so a gated call parks its turn on an
156
- approval dialog. There is no tool card here to host one inline (unlike
157
- the chat's `ask`), so every answerable request is a modal — and the
158
- dialog brings its own backdrop. */}
159
- {dialog && (
160
- <ExtensionDialog
161
- request={dialog.request}
162
- onRespond={(_, response) => mode.respondToDialog(dialog.request.id, response)}
163
- />
164
- )}
165
158
  </div>
166
159
  );
167
160
  }
@@ -3,7 +3,7 @@ import type { AIModelOption } from '@/shared/types';
3
3
  import { ModelDropdown } from '@/client/components/workspace/model-dropdown/index';
4
4
  import { ThinkingLevelDropdown } from '@/client/components/workspace/chat-timeline/chat-input/ThinkingLevelDropdown';
5
5
  import { AccessDropdown } from '@/client/components/workspace/chat-timeline/chat-input/AccessDropdown';
6
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
6
+ import { DEFAULT_APPROVAL_MODE, type ApprovalMode } from '@/shared/lib/omp/config/access-mode';
7
7
 
8
8
  export interface ComposerToolbarProps {
9
9
  isMobile: boolean;
@@ -21,8 +21,12 @@ export interface ComposerToolbarProps {
21
21
  onSend: () => void;
22
22
  /** Send is blocked while no workspace context is selected or nothing is typed. */
23
23
  sendDisabled: boolean;
24
- accessMode: ApprovalMode;
25
- onSelectAccess: (mode: ApprovalMode) => void;
24
+ accessMode?: ApprovalMode;
25
+ onSelectAccess?: (mode: ApprovalMode) => void;
26
+ /** Which selectors render; all true for the chat, all false for a side question. */
27
+ showModel?: boolean;
28
+ showThinking?: boolean;
29
+ showAccess?: boolean;
26
30
  }
27
31
 
28
32
  /**
@@ -45,32 +49,39 @@ export function ComposerToolbar({
45
49
  onStop,
46
50
  onSend,
47
51
  sendDisabled,
48
- accessMode,
52
+ accessMode = DEFAULT_APPROVAL_MODE,
49
53
  onSelectAccess,
54
+ showModel = true,
55
+ showThinking = true,
56
+ showAccess = true,
50
57
  }: ComposerToolbarProps) {
51
58
  const showStop = isGenerating && (isMobile ? Boolean(onStop) : true);
52
59
 
53
60
  return (
54
61
  <div className={`flex items-center justify-between border-t border-ink/5 bg-canvas/50 rounded-b-md ${isMobile ? 'px-2 py-2' : 'px-3 py-2'}`}>
55
62
  <div className={`flex items-center min-w-0 ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
56
- <ModelDropdown
57
- selectedModel={selectedModel ?? undefined}
58
- onSelectModel={onSelectModel}
59
- onThinkingLevelChange={onThinkingLevelChange}
60
- className={isMobile ? 'min-w-0 max-w-[58%]' : undefined}
61
- />
63
+ {showModel && (
64
+ <ModelDropdown
65
+ selectedModel={selectedModel ?? undefined}
66
+ onSelectModel={onSelectModel}
67
+ onThinkingLevelChange={onThinkingLevelChange}
68
+ className={isMobile ? 'min-w-0 max-w-[58%]' : undefined}
69
+ />
70
+ )}
62
71
 
63
- <div className="w-[1px] h-3 bg-ink/10" />
72
+ {showModel && showThinking && <div className="w-[1px] h-3 bg-ink/10" />}
64
73
 
65
- <ThinkingLevelDropdown
66
- thinkingLevels={thinkingLevels}
67
- currentThinking={currentThinking}
68
- onSelect={onSelectThinking}
69
- />
74
+ {showThinking && (
75
+ <ThinkingLevelDropdown
76
+ thinkingLevels={thinkingLevels}
77
+ currentThinking={currentThinking}
78
+ onSelect={onSelectThinking}
79
+ />
80
+ )}
70
81
 
71
- <div className="w-[1px] h-3 bg-ink/10" />
82
+ {(showModel || showThinking) && showAccess && <div className="w-[1px] h-3 bg-ink/10" />}
72
83
 
73
- <AccessDropdown value={accessMode} onSelect={onSelectAccess} />
84
+ {showAccess && onSelectAccess && <AccessDropdown value={accessMode} onSelect={onSelectAccess} />}
74
85
  </div>
75
86
 
76
87
  <div className="flex items-center gap-1.5 flex-shrink-0">
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
2
  import type { ClipboardEvent, SetStateAction } from 'preact/compat';
3
3
  import { AlertTriangle, X } from 'lucide-preact';
4
4
  import type { AIModelOption, Attachment, ModelEntry } from '@/shared/types';
5
- import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
6
5
  import { ComposerToolbar } from '@/client/components/workspace/chat-timeline/chat-input/Toolbar';
7
6
  import { ComposerTextarea } from '@/client/components/common/ComposerTextarea';
8
7
  import { AttachmentToolbar } from '@/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar';
@@ -13,6 +12,7 @@ import { NO_PENDING_PICK } from '@/client/hooks/chat/timeline/deferred-model';
13
12
  import { primeFileReads } from '@/client/hooks/chat/composer/file-reads';
14
13
  import { useComposerPipeline } from '@/client/hooks/chat/composer/pipeline';
15
14
  import { DropOverlay } from '@/client/components/workspace/chat-timeline/chat-input/DropOverlay';
15
+ import type { ChatInputProps } from '@/client/components/workspace/chat-timeline/chat-input/props';
16
16
 
17
17
  export function ChatInput({
18
18
  value,
@@ -33,6 +33,9 @@ export function ChatInput({
33
33
  variant = 'desktop',
34
34
  accessMode,
35
35
  onAccessModeChange,
36
+ showModel = true,
37
+ showThinking = true,
38
+ showAccess = true,
36
39
  enablePicker = true,
37
40
  placeholder,
38
41
  /** Written on every model/thinking pick so the send path can snapshot the
@@ -44,43 +47,7 @@ export function ChatInput({
44
47
  * by composers that never stream (the New Chat modal), where there is
45
48
  * nothing to defer and adoption must behave as before. */
46
49
  deferredComposerPickRef = NO_PENDING_PICK,
47
- }: {
48
- value: string;
49
- onChange: (v: string) => void;
50
- onSend: (attachments: Attachment[], options?: { steering?: boolean }) => void;
51
- isGenerating: boolean;
52
- onStop?: () => void;
53
- className?: string;
54
- disabled?: boolean;
55
- appSettings?: Record<string, any>;
56
- attachments?: Attachment[];
57
- onAttachmentsChange?: (attachments: SetStateAction<Attachment[]>) => void;
58
- onThinkingLevelChange?: (level: string) => void;
59
- onModelChange?: (provider: string, modelId: string) => void;
60
- /** Model last used by the active session (omp `model_change` entry). */
61
- sessionModel?: { provider: string; modelId: string } | null;
62
- /** Thinking level last used by the active session (omp `thinking_level_change` entry). */
63
- sessionThinkingLevel?: string | null;
64
- rootPath?: string | null;
65
- accessMode: ApprovalMode;
66
- onAccessModeChange: (mode: ApprovalMode) => void;
67
- /**
68
- * Whether the `@` / `/` / `!` / `#` autocomplete runs. Off for the
69
- * side-question form, which has no file tree, commands or skills to offer —
70
- * and whose text is never run through the mention translator.
71
- */
72
- enablePicker?: boolean;
73
- /** Overrides the default hint text (the side-question form names its own). */
74
- placeholder?: string;
75
- /**
76
- * `mobile` sizes the composer for a phone: 16px text (iOS Safari zooms the
77
- * viewport when focusing an input below that), thumb-sized send/stop
78
- * targets, and Enter-to-newline instead of Enter-to-send.
79
- */
80
- variant?: 'desktop' | 'mobile';
81
- composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
82
- deferredComposerPickRef?: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
83
- }) {
50
+ }: ChatInputProps) {
84
51
  const [internalAttachments, setInternalAttachments] = useState<Attachment[]>([]);
85
52
 
86
53
  const attachments = externalAttachments !== undefined ? externalAttachments : internalAttachments;
@@ -303,7 +270,7 @@ export function ChatInput({
303
270
  appSettings={appSettings}
304
271
  rootPath={rootPath}
305
272
  enablePicker={enablePicker}
306
- placeholder={placeholder ?? (disabled ? "Please select a workspace above to start prompting..." : "@ for files/agents; / for commands and skills; ! for shell; # for snippets (Paste images/files here)")}
273
+ placeholder={placeholder ?? (disabled ? "Please select a workspace above to start prompting..." : "@ for files/agents; / for commands and skills (Paste images/files here)")}
307
274
  className={`w-full bg-transparent border-none focus:outline-none resize-none text-ink placeholder-ink/40 disabled:opacity-50 disabled:cursor-not-allowed ${
308
275
  isMobile ? 'px-3 py-3 text-base min-h-[68px] max-h-40' : 'px-3 py-3 text-sm min-h-[80px]'
309
276
  }`}
@@ -329,6 +296,9 @@ export function ChatInput({
329
296
  sendDisabled={disabled || (!value.trim() && attachments.length === 0)}
330
297
  accessMode={accessMode}
331
298
  onSelectAccess={onAccessModeChange}
299
+ showModel={showModel}
300
+ showThinking={showThinking}
301
+ showAccess={showAccess}
332
302
  />
333
303
  </div>
334
304
  );
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer's props, split from its implementation so the component file
8
+ * stays under the repo's per-file ceiling. Every field here is documented where
9
+ * it changes behaviour; the component reads them all.
10
+ */
11
+
12
+ import type { SetStateAction } from 'preact/compat';
13
+ import type { Attachment } from '@/shared/types';
14
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
15
+
16
+ export interface ChatInputProps {
17
+ value: string;
18
+ onChange: (v: string) => void;
19
+ onSend: (attachments: Attachment[], options?: { steering?: boolean }) => void;
20
+ isGenerating: boolean;
21
+ onStop?: () => void;
22
+ className?: string;
23
+ disabled?: boolean;
24
+ appSettings?: Record<string, any>;
25
+ attachments?: Attachment[];
26
+ onAttachmentsChange?: (attachments: SetStateAction<Attachment[]>) => void;
27
+ onThinkingLevelChange?: (level: string) => void;
28
+ onModelChange?: (provider: string, modelId: string) => void;
29
+ /** Model last used by the active session (omp `model_change` entry). */
30
+ sessionModel?: { provider: string; modelId: string } | null;
31
+ /** Thinking level last used by the active session (omp `thinking_level_change` entry). */
32
+ sessionThinkingLevel?: string | null;
33
+ rootPath?: string | null;
34
+ /** The chat session's access mode; omit it on a composer whose access
35
+ * selector is off (the side-question form). */
36
+ accessMode?: ApprovalMode;
37
+ onAccessModeChange?: (mode: ApprovalMode) => void;
38
+ /**
39
+ * Which of the toolbar's selectors render. The side-question composer turns
40
+ * all three off: its child runs on the chat's model and thinking selector and
41
+ * without tools, so a control there would change nothing.
42
+ */
43
+ showModel?: boolean;
44
+ showThinking?: boolean;
45
+ showAccess?: boolean;
46
+ /**
47
+ * Whether the `@` / `/` / `!` / `#` autocomplete runs. Off for the
48
+ * side-question form, which has no file tree, commands or skills to offer —
49
+ * and whose text is never run through the mention translator.
50
+ */
51
+ enablePicker?: boolean;
52
+ /** Overrides the default hint text (the side-question form names its own). */
53
+ placeholder?: string;
54
+ /**
55
+ * `mobile` sizes the composer for a phone: 16px text (iOS Safari zooms the
56
+ * viewport when focusing an input below that), thumb-sized send/stop
57
+ * targets, and Enter-to-newline instead of Enter-to-send.
58
+ */
59
+ variant?: 'desktop' | 'mobile';
60
+ composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
61
+ deferredComposerPickRef?: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
62
+ }
@@ -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