ompchamber 0.7.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.e880517fcd.css +2 -0
  3. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  4. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  5. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  6. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  7. package/dist/client/static/js/async/c.797d933882.js +1 -0
  8. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  9. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  10. package/dist/client/static/js/async/g.a06ca3b3e9.js.LICENSE.txt +11 -0
  11. package/dist/client/static/js/async/{m.4b29d34f93.js → m.c038c71b9f.js} +2 -2
  12. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  13. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  15. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  17. package/dist/client/static/js/async/ot.84c71b308f.js.LICENSE.txt +11 -0
  18. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  19. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  20. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  21. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  22. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  23. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  24. package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +12 -0
  25. package/dist/client/static/js/index.0de15c5e17.js +33 -0
  26. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  27. package/package.json +2 -1
  28. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  29. package/src/client/components/common/code-editor/history.test.ts +118 -0
  30. package/src/client/components/common/code-editor/history.ts +101 -0
  31. package/src/client/components/common/code-editor/index.tsx +56 -66
  32. package/src/client/components/common/code-surface/index.tsx +84 -11
  33. package/src/client/components/common/code-surface/measure.ts +66 -17
  34. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  35. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  36. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +2 -1
  37. package/src/client/components/layout/session-sidebar/SessionItem.tsx +11 -4
  38. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  39. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  40. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  41. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  42. package/src/client/components/settings/LazyModal.tsx +1 -1
  43. package/src/client/components/settings/Modal.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +11 -4
  45. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  46. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  47. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  48. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  49. package/src/client/components/workspace/chat-timeline/index.tsx +124 -108
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  60. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  61. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  62. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  63. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  64. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  65. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  66. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  67. package/src/client/data/theme/terminal.ts +27 -29
  68. package/src/client/hooks/chat/omp/session-list.tsx +23 -2
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  70. package/src/client/hooks/chat/timeline/actions.ts +23 -34
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  72. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  73. package/src/client/hooks/chat/timeline/index.ts +18 -28
  74. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  75. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  76. package/src/client/hooks/chat/timeline/send.ts +13 -27
  77. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  78. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  79. package/src/client/hooks/editor/use-code-window.ts +118 -0
  80. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  81. package/src/client/hooks/ui/notification-sound.ts +65 -0
  82. package/src/client/hooks/ui/theme.ts +22 -6
  83. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  84. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  85. package/src/client/tailwind.css +38 -12
  86. package/src/server/db.server.ts +19 -0
  87. package/src/server/lib/fs/git-sync.ts +191 -0
  88. package/src/server/lib/omp/rpc/manager.ts +72 -6
  89. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  90. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  91. package/src/server/lib/omp/session/sidebar-data.server.ts +10 -4
  92. package/src/server/lib/queue/delivery.server.ts +114 -0
  93. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  94. package/src/server/lib/queue/store.server.ts +185 -0
  95. package/src/server/lib/terminal/foreground.ts +73 -0
  96. package/src/server/lib/terminal/history.test.ts +118 -0
  97. package/src/server/lib/terminal/history.ts +205 -0
  98. package/src/server/lib/terminal/runtime.server.ts +338 -0
  99. package/src/server/lib/terminal/session.server.ts +215 -0
  100. package/src/server/lib/terminal/shell.test.ts +97 -0
  101. package/src/server/lib/terminal/shell.ts +103 -0
  102. package/src/server/routes/fs/git.ts +22 -12
  103. package/src/server/routes/index.ts +2 -1
  104. package/src/server/routes/sessions/index.ts +11 -3
  105. package/src/server/routes/sessions/queue.ts +121 -0
  106. package/src/server/routes/sessions/session.ts +1 -132
  107. package/src/server/routes/settings/commands.ts +1 -1
  108. package/src/server/routes/terminal/index.ts +2 -2
  109. package/src/server/routes/terminal/run.ts +8 -100
  110. package/src/server/routes/terminal/sessions.ts +24 -0
  111. package/src/server/routes/terminal/ws.ts +149 -0
  112. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  113. package/src/shared/lib/chat/ask-questions.ts +264 -0
  114. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  115. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  116. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  117. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  118. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  119. package/src/shared/lib/code/lazy-window.ts +156 -0
  120. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  121. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  122. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  123. package/src/shared/lib/markdown/mermaid.ts +8 -6
  124. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  125. package/src/shared/lib/workspace/session-state/store.ts +1 -4
  126. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  127. package/src/shared/types/omp/agent.ts +14 -2
  128. package/src/shared/types/terminal.ts +17 -15
  129. package/src/shared/types/workspace.ts +6 -2
  130. package/dist/client/static/css/index.51f68210a5.css +0 -2
  131. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  132. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  133. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  134. package/dist/client/static/js/async/g.86e2256a70.js +0 -2
  135. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  136. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  137. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  138. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  139. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  140. package/dist/client/static/js/async/uf.24ca3d49c3.js.LICENSE.txt +0 -6
  141. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  142. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  143. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  144. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  145. package/dist/client/static/js/index.eccad0b70a.js +0 -33
  146. package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
  147. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  148. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  149. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  150. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  151. package/src/client/hooks/workspace/terminal.ts +0 -283
  152. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  153. package/src/server/routes/terminal/stream.ts +0 -100
  154. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  155. /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  156. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  157. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  158. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  159. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → ms.98960af4eb.js.LICENSE.txt} +0 -0
  160. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  161. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  162. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  163. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  164. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.0de15c5e17.js.LICENSE.txt} +0 -0
  165. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -7,7 +7,7 @@ interface MobileSessionListProps {
7
7
  activeSessionId: number | string | null;
8
8
  expandedFolders: Record<number, boolean>;
9
9
  showArchived: boolean;
10
- sessionStatus: Record<string, 'stream' | 'finish' | 'abort' | 'error'>;
10
+ sessionStatus: Record<string, 'stream' | 'finish' | 'abort'>;
11
11
  isScrolling: boolean;
12
12
  onScroll: () => void;
13
13
  onSelectSession: (id: number | string) => void;
@@ -5,7 +5,7 @@ import { useInlineRename } from '@/client/hooks/ui/inline-rename';
5
5
  export interface MobileSessionRowProps {
6
6
  session: SessionItemData;
7
7
  isActive: boolean;
8
- status?: 'stream' | 'finish' | 'abort' | 'error';
8
+ status?: 'stream' | 'finish' | 'abort';
9
9
  /** Relative age of the session, or null when no usable timestamp exists. */
10
10
  timeAgo: string | null;
11
11
  showTreeGlyph?: boolean;
@@ -64,7 +64,7 @@ export function MobileSessionRow({
64
64
  <div className="flex items-center space-x-1.5 min-w-0 pr-2">
65
65
  <span className="w-4 flex-shrink-0 flex items-center justify-center">
66
66
  {status === 'stream' && <Loader2 size={13} className="text-ink/50 animate-spin" />}
67
- {(status === 'finish' || status === 'abort' || status === 'error') && <Check size={13} className="text-ink/50" />}
67
+ {(status === 'finish' || status === 'abort') && <Check size={13} className="text-ink/50" />}
68
68
  </span>
69
69
  {showTreeGlyph && <span className="text-ink/40 text-xs flex-shrink-0 font-mono">&gt;</span>}
70
70
  <span className="text-xs truncate leading-snug">
@@ -24,7 +24,7 @@
24
24
  * the stale bootstrap payload.
25
25
  *
26
26
  * `fallback={null}` matches the other lazy dialog boundary
27
- * (`tool-renderers/ask-dialog/Lazy.tsx`): the modal is a full-screen overlay
27
+ * (`tool-renderers/extension-dialog/Lazy.tsx`): the modal is a full-screen overlay
28
28
  * that appears on user intent, so a brief absence reads as the click landing
29
29
  * rather than as a broken surface.
30
30
  */
@@ -3,6 +3,7 @@ import { Check, ChevronLeft, X } from 'lucide-preact';
3
3
  import type { SettingsCategoryId, SettingsState } from '@/shared/types';
4
4
  import { mergeChamberSettings } from '@/shared/lib/settings/client';
5
5
  import { useChamberSettingsWriter } from '@/client/hooks/settings/use-chamber-setting';
6
+ import { applyDocumentTheme } from '@/client/hooks/ui/theme';
6
7
  import { useChamberEvent } from '@/client/hooks/ui/window-event';
7
8
  import { SETTINGS_CATEGORIES, SettingsSidebar } from '@/client/components/settings/Sidebar';
8
9
  import { AppearanceSettings } from '@/client/components/settings/categories/AppearanceSettings';
@@ -100,7 +101,7 @@ export function SettingsModal({
100
101
  setSettings(prev => {
101
102
  const next = typeof updater === 'function' ? updater(prev) : { ...prev, ...updater };
102
103
  if (typeof document !== 'undefined' && next.theme) {
103
- document.documentElement.dataset.theme = next.theme;
104
+ applyDocumentTheme(next.theme);
104
105
  }
105
106
  writeChamberSettings(next);
106
107
  return next;
@@ -13,7 +13,8 @@ interface ComposerDockProps {
13
13
  provider?: string;
14
14
  providerNames?: Record<string, string>;
15
15
  messageQueue: QueuedMessage[];
16
- setMessageQueue: Dispatch<SetStateAction<QueuedMessage[]>>;
16
+ onRemoveQueueItem: (id: string) => void;
17
+ onReorderQueue: (orderedIds: string[]) => void;
17
18
  onEditQueueItem: (item: QueuedMessage) => void;
18
19
  onSendNowQueueItem: (item: QueuedMessage) => void;
19
20
  steeringQueue: QueuedMessage[];
@@ -49,7 +50,8 @@ export function ComposerDock({
49
50
  provider,
50
51
  providerNames,
51
52
  messageQueue,
52
- setMessageQueue,
53
+ onRemoveQueueItem,
54
+ onReorderQueue,
53
55
  onEditQueueItem,
54
56
  onSendNowQueueItem,
55
57
  steeringQueue,
@@ -87,13 +89,18 @@ export function ComposerDock({
87
89
  )}
88
90
  <QueueList
89
91
  queue={messageQueue}
90
- setQueue={setMessageQueue}
92
+ onRemove={onRemoveQueueItem}
93
+ onReorder={onReorderQueue}
91
94
  onEdit={onEditQueueItem}
92
95
  onSendNow={onSendNowQueueItem}
93
96
  />
94
97
  <QueueList
95
98
  queue={steeringQueue}
96
- setQueue={setSteeringQueue}
99
+ onRemove={(id) => setSteeringQueue((q) => q.filter((i) => i.id !== id))}
100
+ onReorder={(orderedIds) => setSteeringQueue((prev) => {
101
+ const byId = new Map(prev.map((i) => [i.id, i]));
102
+ return orderedIds.map((id) => byId.get(id)).filter((i): i is QueuedMessage => Boolean(i));
103
+ })}
97
104
  isSteering
98
105
  />
99
106
  <ChatInput
@@ -1,4 +1,3 @@
1
- import type { Dispatch, SetStateAction } from 'preact/compat';
2
1
  import { useRef } from 'preact/hooks';
3
2
  import { GripVertical, Pencil, Send, X } from 'lucide-preact';
4
3
  import type { QueuedMessage } from '@/shared/types';
@@ -7,7 +6,10 @@ export type { QueuedMessage };
7
6
 
8
7
  interface QueueListProps {
9
8
  queue: QueuedMessage[];
10
- setQueue: Dispatch<SetStateAction<QueuedMessage[]>>;
9
+ /** Reorder handler (drag-and-drop): receives the new id order. */
10
+ onReorder?: (orderedIds: string[]) => void;
11
+ /** Remove one row by id. */
12
+ onRemove?: (id: string) => void;
11
13
  /** True when every row in this panel is a steering delivery. */
12
14
  isSteering?: boolean;
13
15
  onEdit?: (item: QueuedMessage) => void;
@@ -20,18 +22,18 @@ const KIND_LABEL: Record<string, string> = {
20
22
  followup: 'follow-up',
21
23
  };
22
24
 
23
- export function QueueList({ queue, setQueue, isSteering = false, onEdit, onSendNow }: QueueListProps) {
25
+ export function QueueList({ queue, onReorder, onRemove, isSteering = false, onEdit, onSendNow }: QueueListProps) {
24
26
  const dragItem = useRef<number | null>(null);
25
27
  const dragOverItem = useRef<number | null>(null);
26
28
 
27
29
  const handleSort = () => {
28
30
  if (dragItem.current === null || dragOverItem.current === null) return;
29
- const _queue = [...queue];
30
- const draggedItemContent = _queue.splice(dragItem.current, 1)[0];
31
- _queue.splice(dragOverItem.current, 0, draggedItemContent);
31
+ const ids = queue.map((i) => i.id);
32
+ const draggedId = ids.splice(dragItem.current, 1)[0];
33
+ ids.splice(dragOverItem.current, 0, draggedId);
32
34
  dragItem.current = null;
33
35
  dragOverItem.current = null;
34
- setQueue(_queue);
36
+ onReorder?.(ids);
35
37
  };
36
38
 
37
39
  if (queue.length === 0) return null;
@@ -80,13 +82,15 @@ export function QueueList({ queue, setQueue, isSteering = false, onEdit, onSendN
80
82
  <Pencil size={12} />
81
83
  </button>
82
84
  )}
83
- <button
84
- onClick={() => setQueue(q => q.filter(i => i.id !== item.id))}
85
- className="text-ink/40 hover:text-error opacity-0 group-hover:opacity-100 transition-opacity"
86
- title="Remove"
87
- >
88
- <X size={14} />
89
- </button>
85
+ {onRemove && (
86
+ <button
87
+ onClick={() => onRemove(item.id)}
88
+ className="text-ink/40 hover:text-error opacity-0 group-hover:opacity-100 transition-opacity"
89
+ title="Remove"
90
+ >
91
+ <X size={14} />
92
+ </button>
93
+ )}
90
94
  </div>
91
95
  ))}
92
96
  </div>
@@ -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';
@@ -46,7 +49,8 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
46
49
  loadOlder,
47
50
  generatingVerb,
48
51
  messageQueue,
49
- setMessageQueue,
52
+ removeMessage,
53
+ reorderMessages,
50
54
  steeringQueue,
51
55
  setSteeringQueue,
52
56
  inputValue,
@@ -71,12 +75,27 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
71
75
  accessMode,
72
76
  handleAccessModeChange,
73
77
  composerModelRef,
74
- extensionDialog,
75
- closeExtensionDialog,
78
+ extensionDialogs,
79
+ resolveExtensionDialog,
76
80
  respondToExtensionUi,
77
81
  } = useChatTimeline({ folders, appSettings });
78
82
  const { toasts, pushToast, dismissToast } = useToasts();
79
83
 
84
+ // Ask dialogs render on their own tool card; anything else omp is blocked on
85
+ // (an approval gate, an extension picker) has no card and keeps the modal.
86
+ const { framesByTool, modalRequest } = useMemo(
87
+ () => splitAskFrames(localMessages, extensionDialogs),
88
+ [localMessages, extensionDialogs],
89
+ );
90
+ const respondToFrame = useCallback((request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => {
91
+ void respondToExtensionUi(request, response);
92
+ resolveExtensionDialog(request.id);
93
+ }, [respondToExtensionUi, resolveExtensionDialog]);
94
+ const askFrames = useMemo<AskFramesHandle>(
95
+ () => ({ framesByTool, respond: respondToFrame }),
96
+ [framesByTool, respondToFrame],
97
+ );
98
+
80
99
  const [newChatInitialContent, setNewChatInitialContent] = useState<string | null>(null);
81
100
  const { pendingUndo, undoing, requestUndo: handleRequestUndo, closeUndoConfirm, confirmUndo } = useUndoConfirmation(handleUndo);
82
101
  const [searchParams, setSearchParams] = useSearchParams();
@@ -170,118 +189,115 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
170
189
  }
171
190
 
172
191
  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
192
+ <AskFramesContext.Provider value={askFrames}>
193
+ <div className={`flex flex-col h-full min-h-0 overflow-hidden bg-canvas relative ${className}`}>
194
+ {showFullSkeleton ? (
195
+ <SessionSkeleton />
196
+ ) : (
197
+ <>
198
+ {/* Main chat container wrapper */}
199
+ <div className="relative flex-1 min-h-0 flex flex-col">
200
+ {activeSubagent ? (
201
+ <SubagentView
202
+ sessionId={sessionId}
203
+ subagent={activeSubagent}
204
+ onBack={handleSubagentBack}
205
+ provider={sessionProvider}
206
+ providerNames={providerNames}
207
+ />
208
+ ) : (
209
+ <TimelineBody
210
+ isMobile={isMobile}
211
+ userMessages={userMessages}
212
+ onScrollTo={handleScrollTo}
213
+ scrollRef={scrollRef}
214
+ contentRef={contentRef}
215
+ handleScroll={handleScroll}
216
+ isScrolling={isScrolling}
217
+ loadingOlder={loadingOlder}
218
+ hasMore={hasMore}
219
+ loadOlderError={loadOlderError}
220
+ loadOlder={loadOlder}
221
+ messages={localMessages}
222
+ isGenerating={isGenerating}
223
+ provider={sessionProvider}
224
+ providerNames={providerNames}
225
+ modelName={sessionModelName}
226
+ modelNames={modelNames}
227
+ thinkingLevel={sessionData?.thinkingLevel}
228
+ onUndo={handleRequestUndo}
229
+ onRetry={handleRetry}
230
+ onNewChat={handleNewChat}
231
+ showScrollBottom={showScrollBottom}
232
+ jumpToBottom={jumpToBottom}
233
+ />
234
+ )}
235
+ </div>
236
+
237
+ {/* Input Area Footer with Docked Generating Indicator (Seamless & Transparent) */}
238
+ {!activeSubagent && (
239
+ <ComposerDock
190
240
  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
241
  isGenerating={isGenerating}
242
+ modelName={sessionModelName}
243
+ generatingVerb={generatingVerb}
203
244
  provider={sessionProvider}
204
245
  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}
246
+ messageQueue={messageQueue}
247
+ onRemoveQueueItem={removeMessage}
248
+ onReorderQueue={reorderMessages}
249
+ onEditQueueItem={handleEditQueueItem}
250
+ onSendNowQueueItem={handleSendNowQueueItem}
251
+ steeringQueue={steeringQueue}
252
+ setSteeringQueue={setSteeringQueue}
253
+ inputValue={inputValue}
254
+ setInputValue={setInputValue}
255
+ rootPath={composerRoot}
256
+ attachments={inputAttachments}
257
+ setAttachments={setInputAttachments}
258
+ onSend={handleSend}
259
+ onStop={handleStop}
260
+ appSettings={appSettings}
261
+ onThinkingLevelChange={handleThinkingLevelChange}
262
+ onModelChange={handleModelChange}
263
+ accessMode={accessMode}
264
+ onAccessModeChange={handleAccessModeChange}
265
+ composerModelRef={composerModelRef}
266
+ sessionModel={typeof sessionData?.model === 'object' ? sessionData.model : null}
267
+ sessionThinkingLevel={sessionData?.thinkingLevel}
268
+ variant={variant}
213
269
  />
214
270
  )}
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
- )}
271
+ </>
272
+ )}
252
273
 
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
- )}
274
+ {pendingUndo && (
275
+ <UndoConfirmModal
276
+ isOmpSession={Boolean(sessionId) && !sessionId.startsWith('new-') && Number.isNaN(Number(sessionId))}
277
+ content={pendingUndo.content}
278
+ undoing={undoing}
279
+ onClose={closeUndoConfirm}
280
+ onConfirm={confirmUndo}
281
+ />
282
+ )}
283
+ {newChatInitialContent !== null && (
284
+ <NewChatModal
285
+ initialContent={newChatInitialContent}
286
+ onClose={() => setNewChatInitialContent(null)}
287
+ onSend={submitNewChat}
288
+ appSettings={appSettings}
289
+ accessMode={accessMode}
290
+ onAccessModeChange={handleAccessModeChange}
291
+ composerModelRef={composerModelRef}
292
+ />
293
+ )}
273
294
 
274
- {extensionDialog && (
275
- <AskDialog
276
- request={extensionDialog}
277
- onRespond={(request, response) => {
278
- void respondToExtensionUi(request, response);
279
- closeExtensionDialog();
280
- }}
281
- />
282
- )}
295
+ {modalRequest && (
296
+ <ExtensionDialog request={modalRequest} onRespond={respondToFrame} />
297
+ )}
283
298
 
284
- <ToastStack toasts={toasts} onDismiss={dismissToast} />
285
- </div>
299
+ <ToastStack toasts={toasts} onDismiss={dismissToast} />
300
+ </div>
301
+ </AskFramesContext.Provider>
286
302
  );
287
303
  }