ompchamber 0.7.0 → 0.8.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 (107) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  3. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  4. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  5. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  6. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  7. package/dist/client/static/js/async/c.797d933882.js +1 -0
  8. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  9. package/dist/client/static/js/async/{g.86e2256a70.js → g.d1c2fe09aa.js} +2 -2
  10. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  11. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  12. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  13. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  15. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  16. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  17. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  18. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  19. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  20. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  21. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  22. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  23. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  24. package/package.json +1 -1
  25. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  26. package/src/client/components/common/code-editor/history.test.ts +118 -0
  27. package/src/client/components/common/code-editor/history.ts +101 -0
  28. package/src/client/components/common/code-editor/index.tsx +56 -66
  29. package/src/client/components/common/code-surface/index.tsx +84 -11
  30. package/src/client/components/common/code-surface/measure.ts +66 -17
  31. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  32. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  33. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  34. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  35. package/src/client/components/settings/LazyModal.tsx +1 -1
  36. package/src/client/components/settings/Modal.tsx +2 -1
  37. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  38. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  39. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  40. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  41. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  42. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  43. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  44. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  45. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  51. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  52. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  53. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  54. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  55. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  56. package/src/client/hooks/chat/timeline/index.ts +6 -8
  57. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  58. package/src/client/hooks/chat/timeline/send.ts +13 -27
  59. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  60. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  61. package/src/client/hooks/editor/use-code-window.ts +118 -0
  62. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  63. package/src/client/hooks/ui/notification-sound.ts +65 -0
  64. package/src/client/hooks/ui/theme.ts +22 -6
  65. package/src/client/tailwind.css +38 -12
  66. package/src/server/lib/omp/rpc/manager.ts +14 -3
  67. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  68. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  69. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  70. package/src/shared/lib/chat/ask-questions.ts +264 -0
  71. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  72. package/src/shared/lib/code/lazy-window.ts +156 -0
  73. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  74. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  75. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  76. package/src/shared/lib/markdown/mermaid.ts +8 -6
  77. package/src/shared/types/omp/agent.ts +2 -2
  78. package/src/shared/types/workspace.ts +4 -0
  79. package/dist/client/static/css/index.51f68210a5.css +0 -2
  80. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  81. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  82. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  83. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  84. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  85. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  86. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  87. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  88. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  89. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  90. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  91. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  92. package/dist/client/static/js/index.eccad0b70a.js +0 -33
  93. package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
  94. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  95. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  96. /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  97. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  98. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  99. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  100. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  101. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  102. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  103. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  104. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  105. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  106. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  107. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -13,6 +13,7 @@ import { FallbackOutput } from '@/client/components/workspace/chat-timeline/tool
13
13
  import { tryParseJson } from '@/shared/lib/code/syntax-highlight';
14
14
  import { JsonCodeBlock } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock';
15
15
  import { getTodoSummary } from '@/shared/lib/chat/todo-parser';
16
+ import { parseAskQuestions } from '@/shared/lib/chat/ask-questions';
16
17
 
17
18
  interface ToolCallCardProps {
18
19
  tool: ToolCallData;
@@ -199,6 +200,12 @@ export const ToolCallCard = memo(function ToolCallCard({ tool, isOpen, onToggle,
199
200
  .join(' · ');
200
201
  displaySubtitle = cleaned || sub;
201
202
  }
203
+ } else if (toolKey === 'ask') {
204
+ // The question itself is the card's body; the header says how many were
205
+ // asked, matching the summary the same card shows once they are answered.
206
+ displayTitle = 'Question';
207
+ const asked = parseAskQuestions(tool).length;
208
+ if (asked > 0) displaySubtitle = `Asked ${asked} question${asked === 1 ? '' : 's'}`;
202
209
  } else if (mcpToolNameOf(tool)) {
203
210
  // MCP call: `write xd://mcp__<tool>` (or a direct `mcp__<tool>` name) —
204
211
  // the MCP tool names the action, never the transport `write`.
@@ -258,7 +265,7 @@ export const ToolCallCard = memo(function ToolCallCard({ tool, isOpen, onToggle,
258
265
  isOpen={isOpen}
259
266
  onToggle={onToggle ? handleToggle : undefined}
260
267
  defaultExpanded={defaultExpanded}
261
- alwaysExpanded={toolKey === 'yield' && hasPanel}
268
+ alwaysExpanded={hasPanel && (toolKey === 'yield' || toolKey === 'ask')}
262
269
  >
263
270
  <ToolDetailsPanel tool={tool} />
264
271
 
@@ -7,6 +7,7 @@ import { ComposerTextarea } from '@/client/components/common/ComposerTextarea';
7
7
  import { AttachmentToolbar } from '@/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar';
8
8
  import { selectableThinkingLevels } from '@/shared/lib/models/thinking-levels';
9
9
  import { fetchModelsData, subscribeModelsUpdated } from '@/shared/lib/models/client';
10
+ import { resolveThinkingLevel, selectionFor } from '@/client/components/workspace/chat-timeline/chat-input/selection';
10
11
 
11
12
  export function ChatInput({
12
13
  value,
@@ -96,21 +97,11 @@ export function ChatInput({
96
97
  const sessionModelRef = useRef<{ provider: string; modelId: string } | null>(sessionModel ?? null);
97
98
  sessionModelRef.current = sessionModel ?? null;
98
99
 
99
- // Preserve the session's last-used thinking level when applying a model
100
- // picked from the catalog: the session level is authoritative and an async
101
- // fetch resolving later must not reset it to the ladder default.
102
- const resolveSessionLevel = (ladder: readonly string[] | undefined, fallback: string): string => {
103
- const sessionLevel = sessionThinkingLevelRef.current;
104
- if (!sessionLevel) return fallback;
105
- const selectable = selectableThinkingLevels(ladder ?? []);
106
- return selectable.length === 0 || selectable.includes(sessionLevel) ? sessionLevel : fallback;
107
- };
108
-
109
100
  // Adopt the active session's last-used model as the selected model. The
110
- // thinking level must fall back to the CURRENT one, never the ladder
111
- // default: this effect re-runs right after a spawn (sessionModel changes
112
- // while the session's own thinking_level_change entry is not written yet),
113
- // and an `off` fallback here is the "thinking resets to off on send" bug.
101
+ // thinking level must fall back to the CURRENT one, never the ladder default:
102
+ // this effect re-runs right after a spawn (sessionModel changes while the
103
+ // session's own thinking_level_change entry is not readable yet), and any
104
+ // concrete fallback here claims a level the run never used.
114
105
  useEffect(() => {
115
106
  if (!sessionModel?.provider || !sessionModel.modelId) return;
116
107
  let active = true;
@@ -121,14 +112,7 @@ export function ChatInput({
121
112
  (m) => m.id === sessionModel.modelId && m.provider === sessionModel.provider
122
113
  );
123
114
  if (match) {
124
- setSelectedModel(prev => ({
125
- id: match.id,
126
- name: match.name,
127
- provider: match.provider,
128
- contextWindow: match.contextWindow,
129
- thinkingLevels: match.thinkingLevels,
130
- thinkingLevel: resolveSessionLevel(match.thinkingLevels, prev?.thinkingLevel ?? match.thinkingLevels?.[0] ?? 'off'),
131
- }));
115
+ setSelectedModel(prev => selectionFor(match, sessionThinkingLevelRef.current, prev));
132
116
  }
133
117
  })
134
118
  .catch(() => {});
@@ -143,14 +127,7 @@ export function ChatInput({
143
127
  if (!sessionThinkingLevel) return;
144
128
  setSelectedModel(prev => {
145
129
  if (!prev) return prev;
146
- const selectable = selectableThinkingLevels(prev.thinkingLevels ?? []);
147
- // The session level is authoritative (omp recorded it for this model).
148
- // Only reject it when the ladder is KNOWN and explicitly excludes it;
149
- // an empty ladder (catalog not resolved / model exposes none) must not
150
- // fall back to 'off' and erase the session's actual level.
151
- const level = selectable.length === 0 || selectable.includes(sessionThinkingLevel)
152
- ? sessionThinkingLevel
153
- : prev.thinkingLevel;
130
+ const level = resolveThinkingLevel(prev.thinkingLevels, sessionThinkingLevel, prev.thinkingLevel);
154
131
  return level === prev.thinkingLevel ? prev : { ...prev, thinkingLevel: level };
155
132
  });
156
133
  }, [sessionThinkingLevel]);
@@ -169,13 +146,7 @@ export function ChatInput({
169
146
  if (sm?.provider && sm.modelId) {
170
147
  const match = data.modelList.find((m: ModelEntry) => m.id === sm.modelId && m.provider === sm.provider);
171
148
  if (match) {
172
- setSelectedModel(prev => ({
173
- id: match.id,
174
- name: match.name,
175
- provider: match.provider,
176
- thinkingLevels: match.thinkingLevels,
177
- thinkingLevel: resolveSessionLevel(match.thinkingLevels, prev?.thinkingLevel ?? match.thinkingLevels?.[0] ?? 'off'),
178
- }));
149
+ setSelectedModel(prev => selectionFor(match, sessionThinkingLevelRef.current, prev));
179
150
  }
180
151
  return;
181
152
  }
@@ -184,13 +155,7 @@ export function ChatInput({
184
155
  if (persisted?.provider && persisted.id) {
185
156
  const match = data.modelList.find((m: ModelEntry) => m.id === persisted.id && m.provider === persisted.provider);
186
157
  if (match) {
187
- setSelectedModel(prev => ({
188
- id: match.id,
189
- name: match.name,
190
- provider: match.provider,
191
- thinkingLevels: match.thinkingLevels,
192
- thinkingLevel: resolveSessionLevel(match.thinkingLevels, prev?.thinkingLevel ?? match.thinkingLevels?.[0] ?? 'off'),
193
- }));
158
+ setSelectedModel(prev => selectionFor(match, sessionThinkingLevelRef.current, prev));
194
159
  return;
195
160
  }
196
161
  }
@@ -199,20 +164,14 @@ export function ChatInput({
199
164
  if (defaultModel) {
200
165
  const match = data.modelList.find((m: ModelEntry) => m.id === defaultModel.modelId && m.provider === defaultModel.provider);
201
166
  if (match) {
202
- setSelectedModel(prev => ({
203
- id: match.id,
204
- name: match.name,
205
- provider: match.provider,
206
- thinkingLevels: match.thinkingLevels,
207
- thinkingLevel: resolveSessionLevel(match.thinkingLevels, prev?.thinkingLevel ?? match.thinkingLevels?.[0] ?? 'off'),
208
- }));
167
+ setSelectedModel(prev => selectionFor(match, sessionThinkingLevelRef.current, prev));
209
168
  }
210
169
  }
211
170
  } else if (data.selectedModel) {
212
171
  const selected = data.selectedModel;
213
172
  setSelectedModel(prev => ({
214
173
  ...selected,
215
- thinkingLevel: resolveSessionLevel(selected.thinkingLevels, prev?.thinkingLevel ?? selected.thinkingLevel ?? 'off'),
174
+ thinkingLevel: resolveThinkingLevel(selected.thinkingLevels, sessionThinkingLevelRef.current, prev?.thinkingLevel ?? selected.thinkingLevel),
216
175
  }));
217
176
  }
218
177
  } catch {}
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Catalog → composer mapping for the model/thinking pickers: one definition of
8
+ * how a catalog entry becomes the selected model, and of which thinking level
9
+ * that selection is allowed to claim. Shared by every resolution site in
10
+ * ChatInput so the level rule cannot drift between them.
11
+ */
12
+
13
+ import type { AIModelOption, ModelEntry } from '@/shared/types';
14
+ import { selectableThinkingLevels } from '@/shared/lib/models/thinking-levels';
15
+
16
+ /**
17
+ * Level reported while none is known. A catalog ladder's first entry is NOT a
18
+ * fact about the session — every model's ladder starts at `off` — and the send
19
+ * path pushes any non-`auto` value onto the live session (queue replay), so an
20
+ * unresolved level must stay indistinguishable from "leave omp alone".
21
+ */
22
+ export const UNKNOWN_THINKING_LEVEL = 'auto';
23
+
24
+ /**
25
+ * Level the composer may select for a model. The session's last-used level is
26
+ * authoritative whenever the model offers it — an async fetch resolving later
27
+ * must not reset it to a ladder default. Only a ladder that is KNOWN and
28
+ * excludes the session level rejects it; an empty ladder (catalog not resolved
29
+ * / model exposes none) must not erase the session's actual level. With no
30
+ * session level to go on, the current selection is kept, and a composer that
31
+ * has none yet reports {@link UNKNOWN_THINKING_LEVEL}.
32
+ */
33
+ export function resolveThinkingLevel(
34
+ ladder: readonly string[] | null | undefined,
35
+ sessionLevel: string | null | undefined,
36
+ currentLevel: string | null | undefined,
37
+ ): string {
38
+ const selectable = selectableThinkingLevels(ladder ?? []);
39
+ if (sessionLevel && (selectable.length === 0 || selectable.includes(sessionLevel))) return sessionLevel;
40
+ return currentLevel ?? UNKNOWN_THINKING_LEVEL;
41
+ }
42
+
43
+ /** The composer's selection for a catalog model, carrying its thinking level. */
44
+ export function selectionFor(
45
+ match: ModelEntry,
46
+ sessionLevel: string | null | undefined,
47
+ current: AIModelOption | null,
48
+ ): AIModelOption {
49
+ return {
50
+ id: match.id,
51
+ name: match.name,
52
+ provider: match.provider,
53
+ contextWindow: match.contextWindow,
54
+ thinkingLevels: match.thinkingLevels,
55
+ thinkingLevel: resolveThinkingLevel(match.thinkingLevels, sessionLevel, current?.thinkingLevel),
56
+ };
57
+ }
@@ -1,6 +1,9 @@
1
1
  import { useCallback, useMemo, useState } from 'preact/hooks';
2
2
  import { useSearchParams } from '@/client/lib/router/search-params';
3
- import { AskDialog } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-dialog/Lazy';
3
+ import { ExtensionDialog } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
4
+ import { AskFramesContext, splitAskFrames, type AskFramesHandle } from '@/client/hooks/chat/timeline/ask-frames';
5
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
6
+ import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
4
7
  import { EmptyWorkspacePrompt } from '@/client/components/workspace/chat-timeline/EmptyWorkspacePrompt';
5
8
  import { SessionSkeleton } from '@/client/components/workspace/chat-timeline/SessionSkeleton';
6
9
  import { UndoConfirmModal } from '@/client/components/workspace/chat-timeline/UndoConfirmModal';
@@ -71,12 +74,27 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
71
74
  accessMode,
72
75
  handleAccessModeChange,
73
76
  composerModelRef,
74
- extensionDialog,
75
- closeExtensionDialog,
77
+ extensionDialogs,
78
+ resolveExtensionDialog,
76
79
  respondToExtensionUi,
77
80
  } = useChatTimeline({ folders, appSettings });
78
81
  const { toasts, pushToast, dismissToast } = useToasts();
79
82
 
83
+ // Ask dialogs render on their own tool card; anything else omp is blocked on
84
+ // (an approval gate, an extension picker) has no card and keeps the modal.
85
+ const { framesByTool, modalRequest } = useMemo(
86
+ () => splitAskFrames(localMessages, extensionDialogs),
87
+ [localMessages, extensionDialogs],
88
+ );
89
+ const respondToFrame = useCallback((request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => {
90
+ void respondToExtensionUi(request, response);
91
+ resolveExtensionDialog(request.id);
92
+ }, [respondToExtensionUi, resolveExtensionDialog]);
93
+ const askFrames = useMemo<AskFramesHandle>(
94
+ () => ({ framesByTool, respond: respondToFrame }),
95
+ [framesByTool, respondToFrame],
96
+ );
97
+
80
98
  const [newChatInitialContent, setNewChatInitialContent] = useState<string | null>(null);
81
99
  const { pendingUndo, undoing, requestUndo: handleRequestUndo, closeUndoConfirm, confirmUndo } = useUndoConfirmation(handleUndo);
82
100
  const [searchParams, setSearchParams] = useSearchParams();
@@ -170,118 +188,114 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
170
188
  }
171
189
 
172
190
  return (
173
- <div className={`flex flex-col h-full min-h-0 overflow-hidden bg-canvas relative ${className}`}>
174
- {showFullSkeleton ? (
175
- <SessionSkeleton />
176
- ) : (
177
- <>
178
- {/* Main chat container wrapper */}
179
- <div className="relative flex-1 min-h-0 flex flex-col">
180
- {activeSubagent ? (
181
- <SubagentView
182
- sessionId={sessionId}
183
- subagent={activeSubagent}
184
- onBack={handleSubagentBack}
185
- provider={sessionProvider}
186
- providerNames={providerNames}
187
- />
188
- ) : (
189
- <TimelineBody
191
+ <AskFramesContext.Provider value={askFrames}>
192
+ <div className={`flex flex-col h-full min-h-0 overflow-hidden bg-canvas relative ${className}`}>
193
+ {showFullSkeleton ? (
194
+ <SessionSkeleton />
195
+ ) : (
196
+ <>
197
+ {/* Main chat container wrapper */}
198
+ <div className="relative flex-1 min-h-0 flex flex-col">
199
+ {activeSubagent ? (
200
+ <SubagentView
201
+ sessionId={sessionId}
202
+ subagent={activeSubagent}
203
+ onBack={handleSubagentBack}
204
+ provider={sessionProvider}
205
+ providerNames={providerNames}
206
+ />
207
+ ) : (
208
+ <TimelineBody
209
+ isMobile={isMobile}
210
+ userMessages={userMessages}
211
+ onScrollTo={handleScrollTo}
212
+ scrollRef={scrollRef}
213
+ contentRef={contentRef}
214
+ handleScroll={handleScroll}
215
+ isScrolling={isScrolling}
216
+ loadingOlder={loadingOlder}
217
+ hasMore={hasMore}
218
+ loadOlderError={loadOlderError}
219
+ loadOlder={loadOlder}
220
+ messages={localMessages}
221
+ isGenerating={isGenerating}
222
+ provider={sessionProvider}
223
+ providerNames={providerNames}
224
+ modelName={sessionModelName}
225
+ modelNames={modelNames}
226
+ thinkingLevel={sessionData?.thinkingLevel}
227
+ onUndo={handleRequestUndo}
228
+ onRetry={handleRetry}
229
+ onNewChat={handleNewChat}
230
+ showScrollBottom={showScrollBottom}
231
+ jumpToBottom={jumpToBottom}
232
+ />
233
+ )}
234
+ </div>
235
+
236
+ {/* Input Area Footer with Docked Generating Indicator (Seamless & Transparent) */}
237
+ {!activeSubagent && (
238
+ <ComposerDock
190
239
  isMobile={isMobile}
191
- userMessages={userMessages}
192
- onScrollTo={handleScrollTo}
193
- scrollRef={scrollRef}
194
- contentRef={contentRef}
195
- handleScroll={handleScroll}
196
- isScrolling={isScrolling}
197
- loadingOlder={loadingOlder}
198
- hasMore={hasMore}
199
- loadOlderError={loadOlderError}
200
- loadOlder={loadOlder}
201
- messages={localMessages}
202
240
  isGenerating={isGenerating}
241
+ modelName={sessionModelName}
242
+ generatingVerb={generatingVerb}
203
243
  provider={sessionProvider}
204
244
  providerNames={providerNames}
205
- modelName={sessionModelName}
206
- modelNames={modelNames}
207
- thinkingLevel={sessionData?.thinkingLevel}
208
- onUndo={handleRequestUndo}
209
- onRetry={handleRetry}
210
- onNewChat={handleNewChat}
211
- showScrollBottom={showScrollBottom}
212
- jumpToBottom={jumpToBottom}
245
+ messageQueue={messageQueue}
246
+ setMessageQueue={setMessageQueue}
247
+ onEditQueueItem={handleEditQueueItem}
248
+ onSendNowQueueItem={handleSendNowQueueItem}
249
+ steeringQueue={steeringQueue}
250
+ setSteeringQueue={setSteeringQueue}
251
+ inputValue={inputValue}
252
+ setInputValue={setInputValue}
253
+ rootPath={composerRoot}
254
+ attachments={inputAttachments}
255
+ setAttachments={setInputAttachments}
256
+ onSend={handleSend}
257
+ onStop={handleStop}
258
+ appSettings={appSettings}
259
+ onThinkingLevelChange={handleThinkingLevelChange}
260
+ onModelChange={handleModelChange}
261
+ accessMode={accessMode}
262
+ onAccessModeChange={handleAccessModeChange}
263
+ composerModelRef={composerModelRef}
264
+ sessionModel={typeof sessionData?.model === 'object' ? sessionData.model : null}
265
+ sessionThinkingLevel={sessionData?.thinkingLevel}
266
+ variant={variant}
213
267
  />
214
268
  )}
215
- </div>
216
-
217
- {/* Input Area Footer with Docked Generating Indicator (Seamless & Transparent) */}
218
- {!activeSubagent && (
219
- <ComposerDock
220
- isMobile={isMobile}
221
- isGenerating={isGenerating}
222
- modelName={sessionModelName}
223
- generatingVerb={generatingVerb}
224
- provider={sessionProvider}
225
- providerNames={providerNames}
226
- messageQueue={messageQueue}
227
- setMessageQueue={setMessageQueue}
228
- onEditQueueItem={handleEditQueueItem}
229
- onSendNowQueueItem={handleSendNowQueueItem}
230
- steeringQueue={steeringQueue}
231
- setSteeringQueue={setSteeringQueue}
232
- inputValue={inputValue}
233
- setInputValue={setInputValue}
234
- rootPath={composerRoot}
235
- attachments={inputAttachments}
236
- setAttachments={setInputAttachments}
237
- onSend={handleSend}
238
- onStop={handleStop}
239
- appSettings={appSettings}
240
- onThinkingLevelChange={handleThinkingLevelChange}
241
- onModelChange={handleModelChange}
242
- accessMode={accessMode}
243
- onAccessModeChange={handleAccessModeChange}
244
- composerModelRef={composerModelRef}
245
- sessionModel={typeof sessionData?.model === 'object' ? sessionData.model : null}
246
- sessionThinkingLevel={sessionData?.thinkingLevel}
247
- variant={variant}
248
- />
249
- )}
250
- </>
251
- )}
269
+ </>
270
+ )}
252
271
 
253
- {pendingUndo && (
254
- <UndoConfirmModal
255
- isOmpSession={Boolean(sessionId) && !sessionId.startsWith('new-') && Number.isNaN(Number(sessionId))}
256
- content={pendingUndo.content}
257
- undoing={undoing}
258
- onClose={closeUndoConfirm}
259
- onConfirm={confirmUndo}
260
- />
261
- )}
262
- {newChatInitialContent !== null && (
263
- <NewChatModal
264
- initialContent={newChatInitialContent}
265
- onClose={() => setNewChatInitialContent(null)}
266
- onSend={submitNewChat}
267
- appSettings={appSettings}
268
- accessMode={accessMode}
269
- onAccessModeChange={handleAccessModeChange}
270
- composerModelRef={composerModelRef}
271
- />
272
- )}
272
+ {pendingUndo && (
273
+ <UndoConfirmModal
274
+ isOmpSession={Boolean(sessionId) && !sessionId.startsWith('new-') && Number.isNaN(Number(sessionId))}
275
+ content={pendingUndo.content}
276
+ undoing={undoing}
277
+ onClose={closeUndoConfirm}
278
+ onConfirm={confirmUndo}
279
+ />
280
+ )}
281
+ {newChatInitialContent !== null && (
282
+ <NewChatModal
283
+ initialContent={newChatInitialContent}
284
+ onClose={() => setNewChatInitialContent(null)}
285
+ onSend={submitNewChat}
286
+ appSettings={appSettings}
287
+ accessMode={accessMode}
288
+ onAccessModeChange={handleAccessModeChange}
289
+ composerModelRef={composerModelRef}
290
+ />
291
+ )}
273
292
 
274
- {extensionDialog && (
275
- <AskDialog
276
- request={extensionDialog}
277
- onRespond={(request, response) => {
278
- void respondToExtensionUi(request, response);
279
- closeExtensionDialog();
280
- }}
281
- />
282
- )}
293
+ {modalRequest && (
294
+ <ExtensionDialog request={modalRequest} onRespond={respondToFrame} />
295
+ )}
283
296
 
284
- <ToastStack toasts={toasts} onDismiss={dismissToast} />
285
- </div>
297
+ <ToastStack toasts={toasts} onDismiss={dismissToast} />
298
+ </div>
299
+ </AskFramesContext.Provider>
286
300
  );
287
301
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The control for the dialog omp is currently blocked on.
8
+ *
9
+ * The option list is rendered from the FRAME, never from the tool arguments:
10
+ * omp decorates it on its way out — ` (Recommended)` on the suggested choice,
11
+ * `Other (type your own)` appended to every question, and whichever label ends a
12
+ * multi-select sweep — and those exact strings are what omp compares a response
13
+ * against. Reproducing them locally would only risk sending a label omp does not
14
+ * recognise.
15
+ */
16
+
17
+ import { useEffect, useState } from 'preact/hooks';
18
+ import { Check, CornerDownLeft } from 'lucide-preact';
19
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
20
+ import { isOtherOption } from '@/shared/lib/chat/ask-questions';
21
+ import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
22
+
23
+ interface FrameControlProps {
24
+ frame: ExtensionUiDialogRequest;
25
+ /** Question picks several options, so every row toggles instead of answering. */
26
+ multi: boolean;
27
+ /** Labels already toggled on a multi-select question — display only. */
28
+ selected: string[];
29
+ /** Commit a single-choice answer; sent as soon as omp is listening for it. */
30
+ onAnswer: (value: string) => void;
31
+ /** Send one multi-select toggle. */
32
+ onToggle: (label: string) => void;
33
+ /** Answer a dialog that is not a choice list (confirm / input / editor). */
34
+ onRespond: (response: ExtensionDialogResponse) => void;
35
+ }
36
+
37
+ const RECOMMENDED = / \(recommended\)$/i;
38
+ const DONE_ROW = /\bdone\b/i;
39
+
40
+ export function FrameControl({ frame, multi, selected, onAnswer, onToggle, onRespond }: FrameControlProps) {
41
+ const [text, setText] = useState('');
42
+ // A new dialog reuses this component; text left over from the previous one
43
+ // must not read as an answer the user just typed.
44
+ useEffect(() => {
45
+ setText(frame.method === 'editor' ? frame.prefill ?? '' : '');
46
+ }, [frame.id, frame.method, frame.prefill]);
47
+
48
+ if (frame.method === 'confirm') {
49
+ return (
50
+ <div className="mt-2 flex items-center gap-2">
51
+ <button
52
+ type="button"
53
+ onClick={() => onRespond({ confirmed: true })}
54
+ className="flex cursor-pointer items-center gap-1.5 rounded-md bg-ink px-3 py-1 text-[11.5px] font-semibold text-paper transition-opacity hover:opacity-90"
55
+ >
56
+ <Check size={11} />
57
+ <span>Confirm</span>
58
+ </button>
59
+ <button
60
+ type="button"
61
+ onClick={() => onRespond({ confirmed: false })}
62
+ className="cursor-pointer rounded-md border border-ink/15 px-3 py-1 text-[11.5px] font-medium text-ink/70 transition-colors hover:border-ink/30 hover:text-ink"
63
+ >
64
+ Decline
65
+ </button>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ if (frame.method === 'input' || frame.method === 'editor') {
71
+ return (
72
+ <div className="mt-2 space-y-1.5">
73
+ {frame.title && <div className="text-[10.5px] text-ink/45">{frame.title}</div>}
74
+ {frame.method === 'editor' ? (
75
+ <textarea
76
+ autoFocus
77
+ value={text}
78
+ onChange={(event) => setText(event.currentTarget.value)}
79
+ rows={4}
80
+ placeholder={frame.placeholder || 'Type your answer…'}
81
+ className="w-full rounded-md border border-ink/15 bg-canvas/40 px-2.5 py-1.5 font-mono text-[11.5px] leading-relaxed text-ink outline-none focus:border-ink"
82
+ />
83
+ ) : (
84
+ <input
85
+ autoFocus
86
+ value={text}
87
+ placeholder={frame.placeholder || 'Type your answer…'}
88
+ onChange={(event) => setText(event.currentTarget.value)}
89
+ onKeyDown={(event) => {
90
+ if (event.key !== 'Enter') return;
91
+ event.preventDefault();
92
+ onRespond({ value: text });
93
+ }}
94
+ className="w-full rounded-md border border-ink/15 bg-canvas/40 px-2.5 py-1.5 text-[11.5px] text-ink outline-none focus:border-ink"
95
+ />
96
+ )}
97
+ <button
98
+ type="button"
99
+ onClick={() => onRespond({ value: text })}
100
+ className="flex cursor-pointer items-center gap-1.5 rounded-md bg-ink px-3 py-1 text-[11.5px] font-semibold text-paper transition-opacity hover:opacity-90"
101
+ >
102
+ <span>Submit</span>
103
+ <CornerDownLeft size={11} className="opacity-70" />
104
+ </button>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ const options = frame.options ?? [];
110
+ const details = frame.optionDetails ?? [];
111
+ if (frame.method !== 'select' || options.length === 0) return null;
112
+
113
+ return (
114
+ <div className="mt-2 space-y-1" role={multi ? 'group' : 'radiogroup'}>
115
+ {options.map((label, index) => {
116
+ // A single-choice question resolves "Other" to the typed text, so the
117
+ // row itself is a prompt to type — never a literal answer.
118
+ if (!multi && isOtherOption(label)) return null;
119
+ const description = details[index]?.description;
120
+ const checked = multi && selected.includes(label);
121
+ const finish = multi && DONE_ROW.test(label);
122
+ return (
123
+ <button
124
+ key={label}
125
+ type="button"
126
+ onClick={() => (multi ? (finish ? onRespond({ value: label }) : onToggle(label)) : onAnswer(label))}
127
+ className={`flex w-full cursor-pointer items-start gap-2 rounded-md border px-2.5 py-1.5 text-left text-[11.5px] transition-colors ${
128
+ checked
129
+ ? 'border-ink/30 bg-ink/5 text-ink'
130
+ : finish
131
+ ? 'border-ink/20 bg-canvas/60 font-medium text-ink hover:border-ink/40'
132
+ : 'border-ink/8 bg-canvas/30 text-ink/80 hover:border-ink/25 hover:bg-ink/5 hover:text-ink'
133
+ }`}
134
+ >
135
+ <span
136
+ className={`mt-1 h-3 w-3 shrink-0 ${multi ? 'rounded-[3px]' : 'rounded-full'} border ${
137
+ checked ? 'border-ink bg-ink' : 'border-ink/30'
138
+ }`}
139
+ />
140
+ <span className="min-w-0 flex-1">
141
+ <span className="flex items-center gap-1.5 font-medium">
142
+ <span className="truncate">{label}</span>
143
+ {RECOMMENDED.test(label) && !multi && (
144
+ <span className="shrink-0 rounded-full bg-ink/5 px-1.5 py-px font-mono text-[9px] uppercase tracking-wider text-ink/45">
145
+ Suggested
146
+ </span>
147
+ )}
148
+ </span>
149
+ {description && (
150
+ <span className="mt-0.5 block text-[10.5px] leading-relaxed text-ink/50">{description}</span>
151
+ )}
152
+ </span>
153
+ </button>
154
+ );
155
+ })}
156
+
157
+ {!multi && (
158
+ <input
159
+ value={text}
160
+ placeholder="Other — type your own answer…"
161
+ onChange={(event) => setText(event.currentTarget.value)}
162
+ onKeyDown={(event) => {
163
+ if (event.key !== 'Enter' || !text.trim()) return;
164
+ event.preventDefault();
165
+ onAnswer(text.trim());
166
+ }}
167
+ className="w-full rounded-md border border-ink/15 bg-canvas/40 px-2.5 py-1.5 text-[11.5px] text-ink outline-none focus:border-ink"
168
+ />
169
+ )}
170
+ </div>
171
+ );
172
+ }