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
@@ -1,6 +1,7 @@
1
- import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact';
1
+ import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHorizontal } from 'lucide-preact';
2
2
  import type { SessionItemData } from '@/shared/types';
3
3
  import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
+ import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu';
4
5
 
5
6
  export interface MobileSessionRowProps {
6
7
  session: SessionItemData;
@@ -16,6 +17,9 @@ export interface MobileSessionRowProps {
16
17
  onToggleExpand?: () => void;
17
18
  onSelect: () => void;
18
19
  onArchive: () => void;
20
+ /** Omitted for a session that cannot be deleted yet (a pending `new-…` chat,
21
+ * which has no transcript anywhere). */
22
+ onDelete?: () => void;
19
23
  onRename?: (name: string) => void;
20
24
  }
21
25
 
@@ -30,6 +34,7 @@ export function MobileSessionRow({
30
34
  onToggleExpand,
31
35
  onSelect,
32
36
  onArchive,
37
+ onDelete,
33
38
  onRename,
34
39
  }: MobileSessionRowProps) {
35
40
  const {
@@ -41,6 +46,9 @@ export function MobileSessionRow({
41
46
  handleKeyDown,
42
47
  handleBlur,
43
48
  } = useInlineRename(session.title, onRename);
49
+ // One trigger for every row action; also reachable by long-press contextmenu.
50
+ const menu = useRowMenu();
51
+ const hasActions = Boolean(onRename || onArchive || onDelete);
44
52
 
45
53
  // The roster toggle is the row's LAST element, so its glyph lands in the same
46
54
  // column as the folder header's expand chevron — one vertical line of
@@ -71,76 +79,86 @@ export function MobileSessionRow({
71
79
  }
72
80
 
73
81
  return (
74
- <div
75
- className={`w-full rounded-lg flex items-center transition-colors ${
76
- isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
77
- }`}
78
- >
79
- <button
80
- type="button"
81
- onClick={onSelect}
82
- className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
82
+ <>
83
+ <div
84
+ onContextMenu={hasActions ? menu.openAtCursor : undefined}
85
+ className={`w-full rounded-lg flex items-center transition-colors ${
86
+ isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
87
+ }`}
83
88
  >
84
- <div className="flex items-center min-w-0 pr-2">
85
- {/* Status slot, 16px at the same x as the folder header's icon slot,
86
- so the left column reads as one line. Waiting on an answer
87
- outranks the run spinner — the spinner says work is happening,
88
- the question mark says it is YOUR turn (desktop parity). */}
89
- <span className="w-4 flex-shrink-0 flex items-center justify-center">
90
- {awaitingInput ? (
91
- <CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
92
- ) : status === 'stream' ? (
93
- <Loader2 size={13} className="text-ink/50 animate-spin" />
94
- ) : status ? (
95
- <Check size={13} className="text-ink/50" />
96
- ) : null}
97
- </span>
98
- <span className="text-xs truncate leading-snug ml-2">{title}</span>
99
- </div>
100
-
101
- <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
102
- </button>
103
-
104
- {onRename && (
105
89
  <button
106
90
  type="button"
107
- onClick={startRename}
108
- title="Rename session"
109
- className="flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer"
91
+ onClick={onSelect}
92
+ className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
110
93
  >
111
- <Pencil size={14} />
94
+ <div className="flex items-center min-w-0 pr-2">
95
+ {/* Status slot, 16px at the same x as the folder header's icon slot,
96
+ so the left column reads as one line. Waiting on an answer
97
+ outranks the run spinner — the spinner says work is happening,
98
+ the question mark says it is YOUR turn (desktop parity). */}
99
+ <span className="w-4 flex-shrink-0 flex items-center justify-center">
100
+ {awaitingInput ? (
101
+ <CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
102
+ ) : status === 'stream' ? (
103
+ <Loader2 size={13} className="text-ink/50 animate-spin" />
104
+ ) : status ? (
105
+ <Check size={13} className="text-ink/50" />
106
+ ) : null}
107
+ </span>
108
+ <span className="text-xs truncate leading-snug ml-2">{title}</span>
109
+ </div>
110
+
111
+ <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
112
112
  </button>
113
- )}
114
113
 
115
- {/* A row WITHOUT a roster toggle still has to keep the toggle's column
116
- empty, or its archive glyph would sit where the toggle is and the
117
- trailing icons would step in and out row to row. This `mr-1` is what
118
- reserves that column: it moves the 14px glyph's centre from x=363 to
119
- x=359, the exact x the toggle uses on a row that has one. */}
120
- <button
121
- type="button"
122
- onClick={onArchive}
123
- title={session.is_archived === 1 ? 'Unarchive session' : 'Archive session'}
124
- className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
125
- >
126
- {session.is_archived === 1 ? <ArchiveRestore size={14} /> : <Archive size={14} />}
127
- </button>
114
+ {/* One trigger for rename / archive / delete, always visible: a touch
115
+ surface has no hover, and the always-on action buttons this replaced
116
+ consumed a third of a phone's row width.
117
+ `mr-1` only when the row has NO roster toggle — it reserves that
118
+ column so the trailing glyph's centre stays 19px from the row's right
119
+ edge whether the last element is this trigger or the chevron. Without
120
+ it the glyphs step in and out from row to row. */}
121
+ {hasActions && (
122
+ <button
123
+ type="button"
124
+ onClick={menu.openBelow}
125
+ title="Session actions"
126
+ aria-label="Session actions"
127
+ aria-haspopup="menu"
128
+ aria-expanded={menu.anchor !== null}
129
+ className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
130
+ >
131
+ <MoreHorizontal size={16} />
132
+ </button>
133
+ )}
128
134
 
129
- {/* Last element, so its glyph sits in the folder header's chevron column.
130
- `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
131
- touch-sized tap target. */}
132
- {showChevron && (
133
- <button
134
- type="button"
135
- onClick={onToggleExpand}
136
- title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
137
- aria-expanded={isExpanded}
138
- aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
139
- className="flex-shrink-0 p-1.5 mr-1.5 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
140
- >
141
- {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
142
- </button>
135
+ {/* Last element, so its glyph sits in the folder header's chevron column.
136
+ `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
137
+ touch-sized tap target. */}
138
+ {showChevron && (
139
+ <button
140
+ type="button"
141
+ onClick={onToggleExpand}
142
+ title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
143
+ aria-expanded={isExpanded}
144
+ aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
145
+ className="flex-shrink-0 p-1.5 mr-1.5 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
146
+ >
147
+ {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
148
+ </button>
149
+ )}
150
+ </div>
151
+
152
+ {menu.anchor && (
153
+ <SessionActionsMenu
154
+ anchor={menu.anchor}
155
+ isArchived={session.is_archived === 1}
156
+ onRename={onRename ? startRename : undefined}
157
+ onArchive={onArchive}
158
+ onDelete={onDelete}
159
+ onClose={menu.close}
160
+ />
143
161
  )}
144
- </div>
162
+ </>
145
163
  );
146
164
  }
@@ -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
+ }