ompchamber 0.6.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 (125) hide show
  1. package/README.md +24 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  4. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  5. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  6. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  7. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  8. package/dist/client/static/js/async/c.797d933882.js +1 -0
  9. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  10. package/dist/client/static/js/async/{g.8515cbc6f7.js → g.d1c2fe09aa.js} +2 -2
  11. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  12. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  13. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -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/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  17. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  18. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  19. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  20. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  21. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  22. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  23. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  24. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  25. package/package.json +3 -1
  26. package/src/cli/lib/args.js +1 -1
  27. package/src/cli/lib/commands/restart.js +11 -0
  28. package/src/cli/lib/commands/update.js +21 -7
  29. package/src/cli/lib/runtime.js +6 -3
  30. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  31. package/src/client/components/common/code-editor/history.test.ts +118 -0
  32. package/src/client/components/common/code-editor/history.ts +101 -0
  33. package/src/client/components/common/code-editor/index.tsx +56 -66
  34. package/src/client/components/common/code-surface/index.tsx +84 -11
  35. package/src/client/components/common/code-surface/measure.ts +66 -17
  36. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  37. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  38. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  40. package/src/client/components/settings/LazyModal.tsx +1 -1
  41. package/src/client/components/settings/Modal.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  43. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  44. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  45. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  56. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  57. package/src/client/hooks/chat/omp/index.ts +4 -109
  58. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  59. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  60. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  61. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  62. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  63. package/src/client/hooks/chat/timeline/index.ts +6 -8
  64. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  65. package/src/client/hooks/chat/timeline/send.ts +16 -29
  66. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  67. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  68. package/src/client/hooks/editor/use-code-window.ts +118 -0
  69. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  70. package/src/client/hooks/ui/notification-sound.ts +65 -0
  71. package/src/client/hooks/ui/theme.ts +22 -6
  72. package/src/client/tailwind.css +38 -12
  73. package/src/server/index.ts +4 -3
  74. package/src/server/lib/lifecycle/bun.ts +18 -0
  75. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  76. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  77. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  78. package/src/server/lib/lifecycle/restart.ts +111 -0
  79. package/src/server/lib/omp/rpc/manager.ts +14 -3
  80. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  81. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  82. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  83. package/src/server/lib/updates/apply.ts +8 -5
  84. package/src/server/lib/updates/install.ts +7 -4
  85. package/src/server/routes/health/probe.ts +2 -1
  86. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  87. package/src/shared/lib/chat/ask-questions.ts +264 -0
  88. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  89. package/src/shared/lib/code/lazy-window.ts +156 -0
  90. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  91. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  92. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  93. package/src/shared/lib/markdown/mermaid.ts +8 -6
  94. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  95. package/src/shared/types/omp/agent.ts +2 -2
  96. package/src/shared/types/workspace.ts +4 -0
  97. package/dist/client/static/css/index.51f68210a5.css +0 -2
  98. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  99. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  100. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  101. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  102. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  103. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  104. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  105. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  106. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  107. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  108. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  109. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  110. package/dist/client/static/js/index.47d3b04d5f.js +0 -33
  111. package/dist/client/static/js/index.47d3b04d5f.js.LICENSE.txt +0 -4
  112. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  113. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  114. /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  115. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  116. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  117. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  118. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  119. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  120. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  121. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  122. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  123. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  124. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  125. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Prompt delivery for the live omp bridge: warm the session up, attach the
8
+ * event stream, POST the prompt, then signal the sidebars the moment it is
9
+ * dispatched.
10
+ *
11
+ * The signal is the point: the server writes the live `stream` row when it
12
+ * dispatches the prompt, so waiting for `agent_start` (spawn + ack round trip)
13
+ * would leave the sidebar spinner dark for seconds after the user hit send.
14
+ * Extracted from useOmpAgent so that hook stays under the repo's per-file size
15
+ * ceiling.
16
+ */
17
+
18
+ import { useCallback } from 'preact/hooks';
19
+ import type { Dispatch, SetStateAction } from 'preact/compat';
20
+ import type { OmpAgentState } from '@/shared/types';
21
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
22
+
23
+ export interface OmpPromptSenderDeps {
24
+ /** Live session id the prompt targets; the caller mirrors its prop into it. */
25
+ sessionIdRef: { current: string | null };
26
+ /** Attach the event stream to a spawned session. */
27
+ connect: (sessionId: string) => void;
28
+ setState: Dispatch<SetStateAction<OmpAgentState>>;
29
+ }
30
+
31
+ export interface OmpPromptSender {
32
+ sendPrompt: (message: string, images?: { data: string; mimeType: string }[], options?: { accessMode?: ApprovalMode }) => Promise<boolean>;
33
+ sendNewPrompt: (
34
+ message: string,
35
+ cwd: string,
36
+ images?: { data: string; mimeType: string }[],
37
+ composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
38
+ ) => Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null>;
39
+ }
40
+
41
+ export function useOmpPromptSender(deps: OmpPromptSenderDeps): OmpPromptSender {
42
+ const { sessionIdRef, connect, setState } = deps;
43
+
44
+ /** Send a prompt to the omp session via the RPC bridge. */
45
+ const sendPrompt = useCallback(async (
46
+ message: string,
47
+ images?: { data: string; mimeType: string }[],
48
+ options?: { accessMode?: ApprovalMode },
49
+ ) => {
50
+ const sid = sessionIdRef.current;
51
+ if (!sid) return false;
52
+ setState((prev) => ({ ...prev, isGenerating: true, error: null }));
53
+ try {
54
+ // Mirror omp-web handleSend: warm the session process up with get_state
55
+ // (spawns it on first use), then attach the event stream before sending
56
+ // the prompt so no agent events are missed.
57
+ const warmup = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ // The mode rides the warmup too: this is the request that lazily spawns
61
+ // an idle session, and omp only accepts --approval-mode at spawn time.
62
+ body: JSON.stringify({
63
+ type: 'get_state',
64
+ ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
65
+ }),
66
+ });
67
+ if (warmup.ok) connect(sid);
68
+
69
+ const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify({
73
+ type: 'prompt',
74
+ message,
75
+ ...(images?.length ? { images } : {}),
76
+ ...(options?.accessMode ? { accessMode: options.accessMode } : {}),
77
+ }),
78
+ });
79
+ const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
80
+ if (!res.ok || body.error) {
81
+ // `session_busy`: the ack timed out behind a still-running turn, so the
82
+ // prompt may already be accepted — never resend it automatically.
83
+ setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
84
+ return false;
85
+ }
86
+ // Tell the sidebars to re-read the session list now — the server wrote
87
+ // this session's live `stream` row at dispatch. Revalidation is
88
+ // leading-edge throttled, so this lands immediately.
89
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
90
+ return true;
91
+ } catch (e) {
92
+ setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
93
+ return false;
94
+ }
95
+ }, [connect, sessionIdRef, setState]);
96
+
97
+ /** Spawn a brand-new omp session and send the first prompt: ensure_session
98
+ * first (returns omp's real session id), attach the event stream, then send
99
+ * the prompt through the existing session route so no agent events are
100
+ * missed. Model/thinking picks ride the ensure_session body so omp applies
101
+ * them BEFORE the first prompt (and their JSONL change entries are written
102
+ * up front, not after the run starts). Returns the new session id on
103
+ * success, or null on failure — the caller adopts the id as the active
104
+ * session. */
105
+ const sendNewPrompt = useCallback(async (
106
+ message: string,
107
+ cwd: string,
108
+ images?: { data: string; mimeType: string }[],
109
+ composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
110
+ ): Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null> => {
111
+ setState((prev) => ({ ...prev, isGenerating: true, error: null }));
112
+ try {
113
+ const created = await fetch('/api/agent/new', {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({
117
+ type: 'ensure_session',
118
+ cwd,
119
+ ...(composerOptions?.model ? composerOptions.model : {}),
120
+ ...(composerOptions?.thinkingLevel ? { thinkingLevel: composerOptions.thinkingLevel } : {}),
121
+ ...(composerOptions?.accessMode ? { accessMode: composerOptions.accessMode } : {}),
122
+ }),
123
+ });
124
+ const createdBody = (await created.json().catch(() => ({}))) as {
125
+ success?: boolean;
126
+ sessionId?: string;
127
+ model?: { provider: string; modelId: string } | null;
128
+ error?: string;
129
+ };
130
+ if (!created.ok || !createdBody.sessionId) {
131
+ setState((prev) => ({ ...prev, isGenerating: false, error: createdBody.error ?? `HTTP ${created.status}` }));
132
+ return null;
133
+ }
134
+ const sid = createdBody.sessionId;
135
+ connect(sid);
136
+ const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`, {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({
140
+ type: 'prompt',
141
+ message,
142
+ ...(images?.length ? { images } : {}),
143
+ }),
144
+ });
145
+ const body = (await res.json().catch(() => ({}))) as { success?: boolean; error?: string };
146
+ if (!res.ok || body.error) {
147
+ setState((prev) => ({ ...prev, isGenerating: false, error: body.error ?? `HTTP ${res.status}` }));
148
+ return null;
149
+ }
150
+ // Same dispatch-time signal as sendPrompt: the new session's live badge
151
+ // must not wait for agent_start + the first JSONL write.
152
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: sid } }));
153
+ return { sessionId: sid, model: createdBody.model ?? null };
154
+ } catch (e) {
155
+ setState((prev) => ({ ...prev, isGenerating: false, error: e instanceof Error ? e.message : String(e) }));
156
+ return null;
157
+ }
158
+ }, [connect, setState]);
159
+
160
+ return { sendPrompt, sendNewPrompt };
161
+ }
@@ -21,6 +21,7 @@ import type { ReactNode } from 'preact/compat';
21
21
  import { useFetcher } from '@/client/lib/router/fetcher';
22
22
  import { usePanelRefresh } from '@/client/hooks/workspace/panel-refresh';
23
23
  import { SIDEBAR_IDLE_REFRESH_MS } from '@/shared/lib/workspace/refresh-cadence';
24
+ import { useInputRequiredAlert } from '@/client/hooks/ui/input-required-alert';
24
25
  import type { WorkspaceFolderData } from '@/shared/types';
25
26
  import type { SessionListPayload } from '@/server/lib/omp/session/sidebar-data.server';
26
27
 
@@ -87,6 +88,9 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
87
88
  folder.sessions?.some((session) => session.streamStatus === 'stream'),
88
89
  ),
89
90
  );
91
+ // Fires for ANY session, including one this tab never opened — the cue that an
92
+ // agent is blocked on an answer must not depend on the open timeline.
93
+ useInputRequiredAlert(fetcher.data?.folders ?? []);
90
94
  usePanelRefresh(refresh, !hasStreaming, SIDEBAR_IDLE_REFRESH_MS);
91
95
 
92
96
  const markSeen = useCallback((sessionId: number | string) => {
@@ -49,8 +49,6 @@ export interface ChatTimelineActionsDeps {
49
49
  /** Global access-control mode, snapshotted onto queued items. */
50
50
  accessModeRef: { current: ApprovalMode };
51
51
  setSearchParams: (fn: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void;
52
- /** Advance the pending-dialog queue after the head was answered. */
53
- dismissExtensionDialog: () => void;
54
52
  }
55
53
 
56
54
  export interface ChatTimelineActionsResult {
@@ -64,7 +62,6 @@ export interface ChatTimelineActionsResult {
64
62
  stopGenerating: () => number;
65
63
  handleThinkingLevelChange: (level: string) => void;
66
64
  handleModelChange: (provider: string, modelId: string) => void;
67
- closeExtensionDialog: () => void;
68
65
  }
69
66
 
70
67
  export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimelineActionsResult {
@@ -91,7 +88,6 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
91
88
  composerModelRef,
92
89
  accessModeRef,
93
90
  setSearchParams,
94
- dismissExtensionDialog,
95
91
  } = deps;
96
92
 
97
93
  const handleSend = useCallback(async (attachments: Attachment[], options?: { steering?: boolean }) => {
@@ -302,10 +298,6 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
302
298
  void ompAgent.setModel(provider, modelId);
303
299
  }, [isOmpSession, ompAgent, pendingComposerModelRef]);
304
300
 
305
- const closeExtensionDialog = useCallback(() => {
306
- dismissExtensionDialog();
307
- }, [dismissExtensionDialog]);
308
-
309
301
  return {
310
302
  handleSend,
311
303
  handleEditQueueItem,
@@ -316,6 +308,5 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
316
308
  stopGenerating,
317
309
  handleThinkingLevelChange,
318
310
  handleModelChange,
319
- closeExtensionDialog,
320
311
  };
321
312
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Routes omp's pending answerable dialogs to their renderer.
8
+ *
9
+ * `ask` dialogs belong on the tool card that raised them — a modal would hide
10
+ * every question but the one in flight, and the tool's own result (all questions
11
+ * and their answers) is what the user reads afterwards. Everything else
12
+ * (approval gates under `always-ask`, extension pickers) has no card of its own
13
+ * and keeps the modal.
14
+ *
15
+ * The two are told apart by the running ask tool calls: a frame whose title
16
+ * names one of their questions is theirs, and a frame that follows such a frame
17
+ * (the `editor` omp opens after "Other (type your own)") belongs with it too.
18
+ * Any frame left over is modal material — including an ask-shaped frame whose
19
+ * tool call the timeline has not seen, which must stay answerable rather than
20
+ * silently vanish.
21
+ */
22
+
23
+ import { createContext, useContext } from 'preact/compat';
24
+ import type { ChatMessageData } from '@/shared/types';
25
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
26
+ import { groupAskFrames, isAskToolCall, normalizeAskText, parseAskQuestions, type AskQuestion } from '@/shared/lib/chat/ask-questions';
27
+ import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
28
+
29
+ export interface AskFramesSplit {
30
+ /** The ask's dialogs, bucketed per question, keyed by ask tool-call id. */
31
+ framesByTool: Map<string, ExtensionUiDialogRequest[][]>;
32
+ /** Oldest request no ask card owns — the one the modal renders, if any. */
33
+ modalRequest: ExtensionUiDialogRequest | null;
34
+ }
35
+
36
+ export interface AskFramesHandle {
37
+ framesByTool: AskFramesSplit['framesByTool'];
38
+ /** Answer one ask dialog and drop it from the pending queue. */
39
+ respond: (request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => void;
40
+ }
41
+
42
+ const EMPTY: AskFramesHandle = { framesByTool: new Map(), respond: () => {} };
43
+
44
+ export const AskFramesContext = createContext<AskFramesHandle>(EMPTY);
45
+
46
+ /** Pending ask dialogs for the timeline that provides them. */
47
+ export function useAskFrames(): AskFramesHandle {
48
+ return useContext(AskFramesContext);
49
+ }
50
+
51
+ export function splitAskFrames(
52
+ messages: ChatMessageData[],
53
+ pending: ExtensionUiDialogRequest[],
54
+ ): AskFramesSplit {
55
+ const framesByTool = new Map<string, ExtensionUiDialogRequest[][]>();
56
+ if (pending.length === 0) return { framesByTool, modalRequest: null };
57
+
58
+ // Match against EVERY ask the timeline knows, not just the running ones: a
59
+ // reloaded session reads its tool calls back from the JSONL, where a call
60
+ // still parked on a question has no result yet and so cannot be told from a
61
+ // finished one. The pending queue is the part that is authoritative — it only
62
+ // ever holds frames omp is still blocked on — so a title match is enough.
63
+ const openAsks = new Map<string, AskQuestion[]>();
64
+ for (const message of messages) {
65
+ for (const tool of message.toolCalls ?? []) {
66
+ if (!isAskToolCall(tool)) continue;
67
+ const questions = parseAskQuestions(tool);
68
+ if (questions.length > 0) openAsks.set(tool.id, questions);
69
+ }
70
+ }
71
+
72
+ const owned = new Map<string, ExtensionUiDialogRequest[]>();
73
+ const claimed = new Set<ExtensionUiDialogRequest>();
74
+ let lastOwner: string | null = null;
75
+
76
+ for (const request of pending) {
77
+ const title = typeof request.title === 'string' ? normalizeAskText(request.title) : '';
78
+ let owner: string | null = null;
79
+ if (title) {
80
+ for (const [toolId, questions] of openAsks) {
81
+ if (questions.some((question) => normalizeAskText(question.question) === title)) {
82
+ owner = toolId;
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ // The dialog omp opens after "Other (type your own)" names the question in
88
+ // its title only loosely; it continues whichever ask spoke last. An
89
+ // approval gate is never part of an ask, so it keeps its own modal.
90
+ if (!owner && request.method !== 'confirm') owner = lastOwner;
91
+ if (!owner) continue;
92
+ claimed.add(request);
93
+ lastOwner = owner;
94
+ owned.set(owner, [...(owned.get(owner) ?? []), request]);
95
+ }
96
+
97
+ for (const [toolId, frames] of owned) {
98
+ framesByTool.set(toolId, groupAskFrames(openAsks.get(toolId) ?? [], frames));
99
+ }
100
+
101
+ return { framesByTool, modalRequest: pending.find((request) => !claimed.has(request)) ?? null };
102
+ }
@@ -13,19 +13,22 @@
13
13
  * tool call stayed blocked on an approval nobody could answer and the turn never
14
14
  * reached `agent_end` — the run looked hung until Stop.
15
15
  *
16
- * Requests surface one at a time in arrival order; answering the head advances
17
- * the queue. Extracted from useChatTimeline to stay under the size ceiling.
16
+ * The queue keeps EVERY pending request rather than the head alone: `ask` raises
17
+ * one frame per question, and those are rendered inline on the ask tool card
18
+ * (see ask-frames.ts) instead of in a modal, so the head is not the only
19
+ * answerable request at any moment. Entries leave the queue by id once their
20
+ * response is on the wire, or when omp withdraws them (`method: 'cancel'`).
18
21
  */
19
22
 
20
23
  import { useCallback, useEffect, useState } from 'preact/hooks';
21
24
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
22
25
 
23
26
  export interface ExtensionDialogQueue {
24
- /** Head of the queue — the single dialog to render. */
25
- dialog: ExtensionUiDialogRequest | null;
27
+ /** Still-blocking requests, in omp's arrival order. */
28
+ pending: ExtensionUiDialogRequest[];
26
29
  enqueue: (request: ExtensionUiDialogRequest) => void;
27
- /** Advance past the head once its response has been sent. */
28
- dismiss: () => void;
30
+ /** Advance past a request once its response has been sent. */
31
+ resolve: (id: string) => void;
29
32
  /** Drop a request omp withdrew (`method: 'cancel'`) without an answer. */
30
33
  withdraw: (targetId: string) => void;
31
34
  }
@@ -45,12 +48,12 @@ export function useExtensionDialogQueue(sessionId: string | null): ExtensionDial
45
48
  // entry per omp id keeps the queue answerable exactly once.
46
49
  setQueue((current) => (current.some((pending) => pending.id === request.id) ? current : [...current, request]));
47
50
  }, []);
48
- const dismiss = useCallback(() => {
49
- setQueue((current) => current.slice(1));
51
+ const resolve = useCallback((id: string) => {
52
+ setQueue((current) => current.some((pending) => pending.id === id) ? current.filter((pending) => pending.id !== id) : current);
50
53
  }, []);
51
54
  const withdraw = useCallback((targetId: string) => {
52
55
  setQueue((current) => current.filter((pending) => pending.id !== targetId));
53
56
  }, []);
54
57
 
55
- return { dialog: queue[0] ?? null, enqueue, dismiss, withdraw };
58
+ return { pending: queue, enqueue, resolve, withdraw };
56
59
  }
@@ -120,7 +120,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
120
120
  sessionData,
121
121
  adoptedSessionIdRef,
122
122
  refreshSessionMeta,
123
- setSessionModel,
123
+ seedSession,
124
124
  hasMore,
125
125
  loadingOlder,
126
126
  loadOlderError,
@@ -183,9 +183,9 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
183
183
  removeDeliveredFromQueue,
184
184
  } = useChatTimelineQueue(sessionId);
185
185
  const {
186
- dialog: extensionDialog,
186
+ pending: extensionDialogs,
187
187
  enqueue: enqueueExtensionDialog,
188
- dismiss: dismissExtensionDialog,
188
+ resolve: resolveExtensionDialog,
189
189
  withdraw: withdrawExtensionDialog,
190
190
  } = useExtensionDialogQueue(sessionId);
191
191
 
@@ -226,7 +226,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
226
226
  adoptedSessionIdRef,
227
227
  optimisticUserIdRef,
228
228
  pendingUserDisplaysRef,
229
- setSessionModel,
229
+ seedSession,
230
230
  pendingComposerModelRef,
231
231
  pendingThinkingLevelRef,
232
232
  composerModelRef,
@@ -262,7 +262,6 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
262
262
  stopGenerating,
263
263
  handleThinkingLevelChange,
264
264
  handleModelChange,
265
- closeExtensionDialog,
266
265
  } = useChatTimelineActions({
267
266
  inputValue,
268
267
  setInputValue,
@@ -286,7 +285,6 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
286
285
  composerModelRef,
287
286
  accessModeRef,
288
287
  setSearchParams,
289
- dismissExtensionDialog,
290
288
  });
291
289
 
292
290
  return {
@@ -331,8 +329,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
331
329
  accessMode,
332
330
  handleAccessModeChange,
333
331
  composerModelRef,
334
- extensionDialog,
335
- closeExtensionDialog,
332
+ extensionDialogs,
333
+ resolveExtensionDialog,
336
334
  respondToExtensionUi: ompAgent.respondToExtensionUi,
337
335
  };
338
336
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Prompt assembly for the chamber composer: rewrite composer-level mentions
8
+ * into what oh-my-pi actually receives, then inline the text attachments the
9
+ * user dropped in. Extracted from the send path so that hook stays under the
10
+ * repo's per-file size ceiling.
11
+ */
12
+
13
+ import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
14
+ import { loadAgentNames } from '@/shared/lib/chat/composer/client';
15
+ import { translateAgentMentions, translateFileMentions } from '@/shared/lib/chat/composer/translate';
16
+
17
+ type TextFileAttachment = Parameters<typeof composeMessageWithTextAttachments>[1][number];
18
+
19
+ /**
20
+ * Build the outgoing prompt. `@agent` mentions are rewritten into an explicit
21
+ * task-tool delegation directive at send time (oh-my-pi has no `@agent`
22
+ * syntax); translation failures fall back to the raw prompt.
23
+ */
24
+ export async function buildPromptText(text: string, textFiles: TextFileAttachment[]): Promise<string> {
25
+ let translated = text;
26
+ try {
27
+ const names = await loadAgentNames();
28
+ if (names.length > 0) translated = translateAgentMentions(text, names).text;
29
+ } catch {
30
+ // keep the raw prompt
31
+ }
32
+ // File mentions are namespaced (`@file:`) by the picker; strip the namespace
33
+ // only after the agent pass so `@file:<name>` is never mistaken for `@agent`.
34
+ return composeMessageWithTextAttachments(translateFileMentions(translated), textFiles);
35
+ }
@@ -15,33 +15,13 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
15
15
  import type { Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel } from '@/shared/types';
16
16
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
17
17
  import { streamChatResponse } from '@/client/hooks/chat/stream';
18
- import { composeMessageWithTextAttachments, isTextAttachmentFile } from '@/shared/lib/chat/attachments';
19
- import { loadAgentNames } from '@/shared/lib/chat/composer/client';
20
- import { translateAgentMentions, translateFileMentions } from '@/shared/lib/chat/composer/translate';
18
+ import type { SessionSeed } from '@/client/hooks/chat/timeline/session-load';
19
+ import { buildPromptText } from '@/client/hooks/chat/timeline/prompt-text';
20
+ import { isTextAttachmentFile } from '@/shared/lib/chat/attachments';
21
21
  import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
22
22
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
23
23
  import { formatClock } from '@/shared/lib/format/time';
24
24
 
25
- type TextFileAttachment = Parameters<typeof composeMessageWithTextAttachments>[1][number];
26
-
27
- /**
28
- * Build the outgoing prompt. `@agent` mentions are rewritten into an explicit
29
- * task-tool delegation directive at send time (oh-my-pi has no `@agent`
30
- * syntax); translation failures fall back to the raw prompt.
31
- */
32
- async function buildPromptText(text: string, textFiles: TextFileAttachment[]): Promise<string> {
33
- let translated = text;
34
- try {
35
- const names = await loadAgentNames();
36
- if (names.length > 0) translated = translateAgentMentions(text, names).text;
37
- } catch {
38
- // keep the raw prompt
39
- }
40
- // File mentions are namespaced (`@file:`) by the picker; strip the namespace
41
- // only after the agent pass so `@file:<name>` is never mistaken for `@agent`.
42
- return composeMessageWithTextAttachments(translateFileMentions(translated), textFiles);
43
- }
44
-
45
25
  export interface ChatTimelineSendDeps {
46
26
  folders: any[];
47
27
  selectedFolderId: number | null;
@@ -61,7 +41,9 @@ export interface ChatTimelineSendDeps {
61
41
  adoptedSessionIdRef: { current: string | null };
62
42
  optimisticUserIdRef: { current: string | null };
63
43
  pendingUserDisplaysRef: { current: { sent: string; display: string }[] };
64
- setSessionModel: (model: { provider: string; modelId: string } | null) => void;
44
+ /** Adopt the spawned session's identity (model + the thinking level the first
45
+ * prompt runs with) so the composer replacing the pending view shows it. */
46
+ seedSession: (seed: SessionSeed) => void;
65
47
  /** Model/thinking picked in the composer before the session existed —
66
48
  * applied to the spawn command so the first prompt runs with them. */
67
49
  pendingComposerModelRef: { current: { provider: string; modelId: string } | null };
@@ -106,7 +88,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
106
88
  adoptedSessionIdRef,
107
89
  optimisticUserIdRef,
108
90
  pendingUserDisplaysRef,
109
- setSessionModel,
91
+ seedSession,
110
92
  pendingComposerModelRef,
111
93
  pendingThinkingLevelRef,
112
94
  accessModeRef,
@@ -291,7 +273,11 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
291
273
  if (spawned) {
292
274
  adoptedSessionIdRef.current = spawned.sessionId;
293
275
  aiPlaceholderIdRef.current = aiPlaceholderId;
294
- if (spawned.model) setSessionModel(spawned.model);
276
+ // The spawn already applied this level to the session; seed it so the
277
+ // composer taking over from the pending view reports the run's real
278
+ // setting instead of a catalog default (the JSONL that carries it is
279
+ // not locatable yet).
280
+ seedSession({ model: spawned.model, thinkingLevel: composerThinking });
295
281
  pendingComposerModelRef.current = null;
296
282
  pendingThinkingLevelRef.current = null;
297
283
  fetch(`/api/chat/${encodeURIComponent(spawned.sessionId)}`, {
@@ -304,8 +290,9 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
304
290
  next.set('sessionId', spawned.sessionId);
305
291
  return next;
306
292
  }, { replace: true });
307
- // The agent_start event refreshes the session metadata/sidebar once
308
- // the JSONL has the user turn; no immediate refresh is needed here.
293
+ // The sidebar already re-read at prompt dispatch (sendNewPrompt
294
+ // signals it), so the new session shows its live badge without
295
+ // waiting for the JSONL to carry the user turn.
309
296
  return;
310
297
  }
311
298
  }
@@ -342,6 +329,6 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
342
329
  scrollToBottom,
343
330
  })
344
331
  );
345
- }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, setSessionModel, pendingComposerModelRef, pendingThinkingLevelRef, accessModeRef]);
332
+ }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, accessModeRef]);
346
333
  return { steerOmpAgent, executeSend };
347
334
  }