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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.e880517fcd.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/g.a06ca3b3e9.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/ot.84c71b308f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js.LICENSE.txt +12 -0
- package/dist/client/static/js/index.0de15c5e17.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +2 -1
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +11 -4
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +11 -4
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +124 -108
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/omp/session-list.tsx +23 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +23 -34
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +18 -28
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +13 -27
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/client/tailwind.css +38 -12
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/omp/rpc/manager.ts +72 -6
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +10 -4
- package/src/server/lib/queue/delivery.server.ts +114 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +1 -1
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/workspace/session-state/store.ts +1 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/omp/agent.ts +14 -2
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +6 -2
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/g.86e2256a70.js +0 -2
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/uf.24ca3d49c3.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.eccad0b70a.js +0 -33
- package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → ms.98960af4eb.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.0de15c5e17.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mounts an xterm instance on a container and keeps it bound to a PTY.
|
|
8
|
+
*
|
|
9
|
+
* Extracted from the view component so the mount lifecycle (xterm import, fit
|
|
10
|
+
* addon, input wiring, observers) stays readable and the component remains a
|
|
11
|
+
* render layer. Everything the caller must know travels through `callbacks`;
|
|
12
|
+
* the returned handle is the only way back in.
|
|
13
|
+
*
|
|
14
|
+
* Two behaviours here are deliberate:
|
|
15
|
+
*
|
|
16
|
+
* - **Attach waits for a real grid.** The terminal panel stays mounted while
|
|
17
|
+
* its view is CSS-hidden (that is what keeps a shell alive across panel
|
|
18
|
+
* switches), and a hidden container measures 0px — fitting is skipped and
|
|
19
|
+
* xterm keeps its 80x24 default. Attaching then would create the shell at a
|
|
20
|
+
* size the panel never had, and the retained scrollback would replay at that
|
|
21
|
+
* width forever. So attach is driven by the same ResizeObserver that detects
|
|
22
|
+
* the panel becoming visible.
|
|
23
|
+
* - **Raw bytes in, raw bytes out.** `onData`/`onBinary` forward keystrokes
|
|
24
|
+
* untouched, and `write` takes a `Uint8Array` so xterm's streaming UTF-8
|
|
25
|
+
* decoder reassembles a character split across two PTY reads.
|
|
26
|
+
* - **xterm and the WebGL addon are dynamically imported on purpose.** xterm is
|
|
27
|
+
* ~300 KB and the addon pulls a WebGL context into the panel; both stay out
|
|
28
|
+
* of the initial client bundle until the terminal panel is actually opened.
|
|
29
|
+
* The module shape varies by build, hence the probe pattern below.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Terminal } from '@xterm/xterm';
|
|
33
|
+
import type { FitAddon } from '@xterm/addon-fit';
|
|
34
|
+
import type { WebglAddon } from '@xterm/addon-webgl';
|
|
35
|
+
import {
|
|
36
|
+
getXtermTheme,
|
|
37
|
+
XTERM_FONT_FAMILY,
|
|
38
|
+
safePatchFitAddon,
|
|
39
|
+
safePatchRenderService,
|
|
40
|
+
type XtermCore,
|
|
41
|
+
type XtermModule,
|
|
42
|
+
} from '@/client/data/theme/terminal';
|
|
43
|
+
|
|
44
|
+
export interface XtermMountCallbacks {
|
|
45
|
+
/** Keystrokes, paste, mouse reports — straight to the PTY. */
|
|
46
|
+
onInput: (data: string) => void;
|
|
47
|
+
/** Non-UTF-8 payloads xterm reports separately (some mouse reports). */
|
|
48
|
+
onBinaryInput: (data: string) => void;
|
|
49
|
+
/** The grid changed; the PTY must be resized to match. */
|
|
50
|
+
onGridChange: (cols: number, rows: number) => void;
|
|
51
|
+
/** The terminal is measurable and knows its size; attach with these. */
|
|
52
|
+
onReady: (cols: number, rows: number) => void;
|
|
53
|
+
/** Viewport scrolled away from the live bottom. */
|
|
54
|
+
onScrolledUpChange: (scrolledUp: boolean) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface XtermMount {
|
|
58
|
+
term: Terminal;
|
|
59
|
+
fitAddon: FitAddon;
|
|
60
|
+
/**
|
|
61
|
+
* Lay the retained scrollback out at the size it was drawn for, then fit the
|
|
62
|
+
* panel. Grid reports are suppressed until the replay has been consumed.
|
|
63
|
+
*/
|
|
64
|
+
writeReplay: (bytes: Uint8Array, cols: number, rows: number) => void;
|
|
65
|
+
dispose: () => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Mount xterm inside `container`. Resolves to null when the module could not be
|
|
70
|
+
* loaded or the component unmounted while importing.
|
|
71
|
+
*/
|
|
72
|
+
export async function mountXterm(
|
|
73
|
+
container: HTMLElement,
|
|
74
|
+
isDark: boolean,
|
|
75
|
+
callbacks: XtermMountCallbacks,
|
|
76
|
+
isCancelled: () => boolean,
|
|
77
|
+
): Promise<XtermMount | null> {
|
|
78
|
+
// Dynamic on purpose: xterm is ~300 KB and is only needed once the terminal
|
|
79
|
+
// panel is actually opened, so it stays out of the initial client bundle
|
|
80
|
+
// (the module shape varies by build, hence the probes).
|
|
81
|
+
const xtermModule = (await import('@xterm/xterm')) as unknown as XtermModule<typeof Terminal>;
|
|
82
|
+
const fitModule = (await import('@xterm/addon-fit')) as unknown as XtermModule<typeof FitAddon>;
|
|
83
|
+
|
|
84
|
+
const TerminalClass = xtermModule.Terminal || xtermModule.default?.Terminal || xtermModule.default;
|
|
85
|
+
const FitAddonClass = fitModule.FitAddon || fitModule.default?.FitAddon || fitModule.default;
|
|
86
|
+
if (!TerminalClass || !FitAddonClass || isCancelled()) return null;
|
|
87
|
+
|
|
88
|
+
safePatchFitAddon(FitAddonClass);
|
|
89
|
+
|
|
90
|
+
const term = new TerminalClass({
|
|
91
|
+
cursorBlink: true,
|
|
92
|
+
cursorStyle: 'block',
|
|
93
|
+
fontSize: 12,
|
|
94
|
+
lineHeight: 1.25,
|
|
95
|
+
fontFamily: XTERM_FONT_FAMILY,
|
|
96
|
+
theme: getXtermTheme(isDark),
|
|
97
|
+
// No `convertEol`: the PTY already emits CRLF, and converting a lone LF
|
|
98
|
+
// would re-introduce the line-feed doubling a real terminal avoids.
|
|
99
|
+
scrollback: 10000,
|
|
100
|
+
scrollSensitivity: 1.5,
|
|
101
|
+
fastScrollSensitivity: 5,
|
|
102
|
+
smoothScrollDuration: 0,
|
|
103
|
+
allowProposedApi: true,
|
|
104
|
+
}) as Terminal;
|
|
105
|
+
|
|
106
|
+
term.open(container);
|
|
107
|
+
safePatchRenderService(term);
|
|
108
|
+
|
|
109
|
+
// WebGL renderer: draws powerline glyphs (U+E0B0 etc.) as full-cell vector
|
|
110
|
+
// paths, so segmented prompts stay seamless the way a local terminal does.
|
|
111
|
+
// The DOM renderer draws them as ordinary glyphs inside the font box, which
|
|
112
|
+
// leaves notches at every segment joint. WebGL can fail (no GPU context,
|
|
113
|
+
// context-lost), and the DOM renderer is the automatic fallback.
|
|
114
|
+
try {
|
|
115
|
+
const webglModule = (await import('@xterm/addon-webgl')) as unknown as {
|
|
116
|
+
WebglAddon?: typeof WebglAddon;
|
|
117
|
+
default?: { WebglAddon?: typeof WebglAddon } & typeof WebglAddon;
|
|
118
|
+
};
|
|
119
|
+
const WebglAddonClass = webglModule.WebglAddon || webglModule.default?.WebglAddon || webglModule.default;
|
|
120
|
+
if (WebglAddonClass) {
|
|
121
|
+
const webgl = new WebglAddonClass() as WebglAddon;
|
|
122
|
+
webgl.onContextLoss(() => webgl.dispose());
|
|
123
|
+
term.loadAddon(webgl);
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
|
|
127
|
+
const fitAddon = new FitAddonClass() as FitAddon;
|
|
128
|
+
term.loadAddon(fitAddon);
|
|
129
|
+
|
|
130
|
+
term.attachCustomKeyEventHandler((event: globalThis.KeyboardEvent) => handleKeyNavigation(term, event));
|
|
131
|
+
|
|
132
|
+
const disposables = [
|
|
133
|
+
term.onScroll(() => {
|
|
134
|
+
const buffer = term.buffer.active;
|
|
135
|
+
callbacks.onScrolledUpChange(buffer.viewportY < buffer.baseY);
|
|
136
|
+
}),
|
|
137
|
+
term.onData((data: string) => callbacks.onInput(data)),
|
|
138
|
+
term.onBinary((data: string) => callbacks.onBinaryInput(data)),
|
|
139
|
+
// FitAddon changes the grid on fit; the shell has to hear about it or a TUI
|
|
140
|
+
// keeps drawing at the old width. Reports are dropped while the replay is
|
|
141
|
+
// being laid out: that grid belongs to the recorded history, not the panel.
|
|
142
|
+
term.onResize(({ cols, rows }) => {
|
|
143
|
+
if (replaying) return;
|
|
144
|
+
callbacks.onGridChange(cols, rows);
|
|
145
|
+
}),
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
let attached = false;
|
|
149
|
+
let fitTimer: ReturnType<typeof setTimeout> | null = null;
|
|
150
|
+
let frame: number | null = null;
|
|
151
|
+
/**
|
|
152
|
+
* Set while the retained scrollback is being laid out at its recorded size.
|
|
153
|
+
* That transient grid is not the panel's, so it must not be reported as a
|
|
154
|
+
* resize — the shell would answer with a prompt repaint for a size the user
|
|
155
|
+
* never chose, which is how a reattach used to print a second prompt.
|
|
156
|
+
*/
|
|
157
|
+
let replaying = false;
|
|
158
|
+
|
|
159
|
+
const safeFit = () => {
|
|
160
|
+
if (isCancelled()) return;
|
|
161
|
+
if (container.clientWidth <= 0 || container.clientHeight <= 0) return;
|
|
162
|
+
const core = (term as unknown as XtermCore)._core;
|
|
163
|
+
const dims = core?._renderService?.dimensions;
|
|
164
|
+
if (!dims?.css?.cell?.width || !dims?.css?.cell?.height) return;
|
|
165
|
+
try {
|
|
166
|
+
fitAddon.fit();
|
|
167
|
+
} catch {}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** Fit now, once xterm has consumed everything already queued. */
|
|
171
|
+
const fitAfterWrites = () => {
|
|
172
|
+
term.write('', () => {
|
|
173
|
+
if (isCancelled()) return;
|
|
174
|
+
replaying = false;
|
|
175
|
+
safeFit();
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Lay the retained scrollback out at the size it was drawn for, then converge
|
|
181
|
+
* on the panel's grid. Re-wrapping history at another width leaves wrapped
|
|
182
|
+
* fragments the shell's own redraw never clears.
|
|
183
|
+
*/
|
|
184
|
+
const writeReplay = (bytes: Uint8Array, cols: number, rows: number) => {
|
|
185
|
+
replaying = true;
|
|
186
|
+
if (term.cols !== cols || term.rows !== rows) term.resize(cols, rows);
|
|
187
|
+
if (bytes.length === 0) {
|
|
188
|
+
fitAfterWrites();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
term.write(bytes, fitAfterWrites);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const attachIfSized = () => {
|
|
195
|
+
if (attached || isCancelled()) return;
|
|
196
|
+
if (container.clientWidth <= 0 || container.clientHeight <= 0) return;
|
|
197
|
+
const core = (term as unknown as XtermCore)._core;
|
|
198
|
+
if (!core?._renderService?.dimensions?.css?.cell?.width) return;
|
|
199
|
+
attached = true;
|
|
200
|
+
callbacks.onReady(term.cols, term.rows);
|
|
201
|
+
term.focus();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
205
|
+
if (fitTimer) clearTimeout(fitTimer);
|
|
206
|
+
fitTimer = setTimeout(() => {
|
|
207
|
+
safeFit();
|
|
208
|
+
// A panel that was hidden at mount becomes measurable here, which is when
|
|
209
|
+
// the shell should be created — at its real size.
|
|
210
|
+
attachIfSized();
|
|
211
|
+
}, 35);
|
|
212
|
+
});
|
|
213
|
+
resizeObserver.observe(container);
|
|
214
|
+
|
|
215
|
+
const touch = attachTouchScroll(container, term);
|
|
216
|
+
|
|
217
|
+
// The container may already be measurable (the view was active at mount), in
|
|
218
|
+
// which case the observer never fires a first time.
|
|
219
|
+
frame = requestAnimationFrame(() => {
|
|
220
|
+
safeFit();
|
|
221
|
+
attachIfSized();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
term,
|
|
226
|
+
fitAddon,
|
|
227
|
+
writeReplay,
|
|
228
|
+
dispose() {
|
|
229
|
+
if (frame !== null) cancelAnimationFrame(frame);
|
|
230
|
+
clearTimeout(fitTimer ?? undefined);
|
|
231
|
+
touch();
|
|
232
|
+
for (const disposable of disposables) disposable.dispose();
|
|
233
|
+
resizeObserver.disconnect();
|
|
234
|
+
try {
|
|
235
|
+
fitAddon.dispose();
|
|
236
|
+
} catch {}
|
|
237
|
+
try {
|
|
238
|
+
term.dispose();
|
|
239
|
+
} catch {}
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Shift+PageUp/Home/End scroll the viewport instead of reaching the shell. */
|
|
245
|
+
function handleKeyNavigation(term: Terminal, event: globalThis.KeyboardEvent): boolean {
|
|
246
|
+
if (event.type !== 'keydown' || !event.shiftKey) return true;
|
|
247
|
+
if (event.key === 'PageUp') { term.scrollPages(-1); return false; }
|
|
248
|
+
if (event.key === 'PageDown') { term.scrollPages(1); return false; }
|
|
249
|
+
if (event.key === 'Home') { term.scrollToTop(); return false; }
|
|
250
|
+
if (event.key === 'End') { term.scrollToBottom(); return false; }
|
|
251
|
+
if (event.key === 'ArrowUp') { term.scrollLines(-1); return false; }
|
|
252
|
+
if (event.key === 'ArrowDown') { term.scrollLines(1); return false; }
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Touch drags scroll the scrollback; xterm has no touch handling of its own. */
|
|
257
|
+
function attachTouchScroll(container: HTMLElement, term: Terminal): () => void {
|
|
258
|
+
let startY = 0;
|
|
259
|
+
const onStart = (event: globalThis.TouchEvent) => {
|
|
260
|
+
if (event.touches.length === 1) startY = event.touches[0].clientY;
|
|
261
|
+
};
|
|
262
|
+
const onMove = (event: globalThis.TouchEvent) => {
|
|
263
|
+
if (event.touches.length !== 1) return;
|
|
264
|
+
const deltaY = startY - event.touches[0].clientY;
|
|
265
|
+
if (Math.abs(deltaY) < 16) return;
|
|
266
|
+
term.scrollLines(Math.trunc(deltaY / 16));
|
|
267
|
+
startY = event.touches[0].clientY;
|
|
268
|
+
};
|
|
269
|
+
container.addEventListener('touchstart', onStart, { passive: true });
|
|
270
|
+
container.addEventListener('touchmove', onMove, { passive: true });
|
|
271
|
+
return () => {
|
|
272
|
+
container.removeEventListener('touchstart', onStart);
|
|
273
|
+
container.removeEventListener('touchmove', onMove);
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -54,7 +54,33 @@ export function getXtermTheme(isDark: boolean) {
|
|
|
54
54
|
|
|
55
55
|
export const XTERM_THEME = XTERM_DARK_THEME;
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Font stack for the terminal canvas.
|
|
59
|
+
*
|
|
60
|
+
* The glyph fallbacks after the text faces are load-bearing. A shell prompt is
|
|
61
|
+
* usually drawn with private-use symbols (powerline separators, devicons,
|
|
62
|
+
* Material Design icons) that **no** coding webfont contains — `@fontsource/
|
|
63
|
+
* fira-code`'s latin subset maps 226 codepoints and not one of them is above
|
|
64
|
+
* U+FFFF — so without a face that supplies them the browser paints a hollow
|
|
65
|
+
* tofu box for every prompt segment. The names below cover the Nerd Font
|
|
66
|
+
* families a user is most likely to have installed (plus the *fontconfig*
|
|
67
|
+
* symbol faces on Linux); a face that is absent is simply skipped by the
|
|
68
|
+
* normal fallback chain, so listing many costs nothing.
|
|
69
|
+
*
|
|
70
|
+
* This is the same shape OpenChamber ships (`TERMINAL_GLYPH_FALLBACKS` in its
|
|
71
|
+
* `ghostty/surface.ts`), which additionally bundles `SymbolsNerdFontMono` as a
|
|
72
|
+
* webfont so the coverage never depends on the machine.
|
|
73
|
+
*
|
|
74
|
+
* Only glyphs the text face lacks reach these entries, so Latin metrics are
|
|
75
|
+
* untouched — verified: `A`/`m`/`W` keep identical advance and ink.
|
|
76
|
+
*/
|
|
77
|
+
export const XTERM_GLYPH_FALLBACKS =
|
|
78
|
+
"'FiraCode Nerd Font Mono', 'FiraCode Nerd Font', 'JetBrainsMono Nerd Font', " +
|
|
79
|
+
"'JetBrainsMono NF', 'JetBrains Mono Nerd Font', 'Hack Nerd Font', 'MesloLGS NF', " +
|
|
80
|
+
"'MesloLGM Nerd Font', 'CaskaydiaCove Nerd Font', 'SauceCodePro Nerd Font', " +
|
|
81
|
+
"'Symbols Nerd Font Mono', 'Symbols Nerd Font', 'PowerlineSymbols'";
|
|
82
|
+
|
|
83
|
+
export const XTERM_FONT_FAMILY = `'Fira Code', ${XTERM_GLYPH_FALLBACKS}, Menlo, Monaco, 'Courier New', monospace`;
|
|
58
84
|
|
|
59
85
|
/** xterm's private render service, read only to size-check before fitting. */
|
|
60
86
|
export interface XtermCore {
|
|
@@ -120,31 +146,3 @@ export function safePatchRenderService(term: any) {
|
|
|
120
146
|
}
|
|
121
147
|
} catch {}
|
|
122
148
|
}
|
|
123
|
-
|
|
124
|
-
let terminalSessionOutput = '';
|
|
125
|
-
|
|
126
|
-
export function getTerminalSessionOutput(): string {
|
|
127
|
-
return terminalSessionOutput;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function appendTerminalSessionOutput(chunk: string): void {
|
|
131
|
-
terminalSessionOutput += chunk;
|
|
132
|
-
if (terminalSessionOutput.length > 250000) {
|
|
133
|
-
terminalSessionOutput = terminalSessionOutput.slice(-200000);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function clearTerminalSessionOutput(): void {
|
|
138
|
-
terminalSessionOutput = '';
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const TERMINAL_OUTPUT_SNAPSHOT_LIMIT = 8000;
|
|
142
|
-
|
|
143
|
-
export function getTerminalOutputSnapshot(): string {
|
|
144
|
-
if (terminalSessionOutput.length <= TERMINAL_OUTPUT_SNAPSHOT_LIMIT) return terminalSessionOutput;
|
|
145
|
-
return terminalSessionOutput.slice(-TERMINAL_OUTPUT_SNAPSHOT_LIMIT);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function setTerminalOutputSnapshot(value: string): void {
|
|
149
|
-
terminalSessionOutput = value;
|
|
150
|
-
}
|
|
@@ -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) => {
|
|
@@ -112,11 +116,28 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
|
|
|
112
116
|
const value = useMemo<SidebarDataHandle>(() => {
|
|
113
117
|
const data = fetcher.data;
|
|
114
118
|
const seen = seenRef.current;
|
|
119
|
+
// A session that streams again earns a fresh badge lifecycle: forget the
|
|
120
|
+
// ack from an earlier run. Without this, a session opened with a terminal
|
|
121
|
+
// badge stays stripped for the whole mount — its spinner never renders no
|
|
122
|
+
// matter how many times the list refetches, and only a page reload (fresh
|
|
123
|
+
// mount) brings it back.
|
|
124
|
+
if (seen.size && data?.folders) {
|
|
125
|
+
for (const folder of data.folders) {
|
|
126
|
+
for (const session of folder.sessions ?? []) {
|
|
127
|
+
if (session.streamStatus === 'stream') seen.delete(String(session.id));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
115
131
|
return {
|
|
116
132
|
folders: ((data?.folders ?? initialFolders) as WorkspaceFolderData[])
|
|
117
|
-
// Optimistic badge strip for sessions marked seen this mount.
|
|
133
|
+
// Optimistic badge strip for sessions marked seen this mount. NEVER
|
|
134
|
+
// strip a live `stream` row — the spinner must always render.
|
|
118
135
|
.map((f) => seen.size
|
|
119
|
-
? { ...f, sessions: (f.sessions ?? []).map((s) => (
|
|
136
|
+
? { ...f, sessions: (f.sessions ?? []).map((s) => (
|
|
137
|
+
seen.has(String(s.id)) && s.streamStatus && s.streamStatus !== 'stream'
|
|
138
|
+
? { ...s, streamStatus: undefined }
|
|
139
|
+
: s
|
|
140
|
+
)) }
|
|
120
141
|
: f),
|
|
121
142
|
isMock: data?.isMock ?? false,
|
|
122
143
|
initializing: !hasLoaded,
|
|
@@ -62,7 +62,7 @@ export interface SessionSidebarController {
|
|
|
62
62
|
sortOption: SessionSortOption;
|
|
63
63
|
handleSortChange: (opt: SessionSortOption) => void;
|
|
64
64
|
processedFolders: WorkspaceFolderData[];
|
|
65
|
-
sessionStatus: Record<string, 'stream' | 'finish' | 'abort'
|
|
65
|
+
sessionStatus: Record<string, 'stream' | 'finish' | 'abort'>;
|
|
66
66
|
handleSelectSession: (id: number | string) => void;
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -28,7 +28,9 @@ export interface ChatTimelineActionsDeps {
|
|
|
28
28
|
sessionId: string | null;
|
|
29
29
|
appSettings: Record<string, any>;
|
|
30
30
|
messageQueue: QueuedMessage[];
|
|
31
|
-
|
|
31
|
+
/** Server-backed per-item queue ops (append/remove/reorder). */
|
|
32
|
+
enqueueMessage: (item: Omit<QueuedMessage, 'id'>) => void;
|
|
33
|
+
removeMessage: (id: string) => void;
|
|
32
34
|
executeSend: (text: string, attachments: Attachment[], options?: { model?: QueuedMessageModel | null }) => Promise<void>;
|
|
33
35
|
steerOmpAgent: (text: string, attachments: Attachment[]) => Promise<void>;
|
|
34
36
|
ompAgent: OmpAgentHandle;
|
|
@@ -49,8 +51,6 @@ export interface ChatTimelineActionsDeps {
|
|
|
49
51
|
/** Global access-control mode, snapshotted onto queued items. */
|
|
50
52
|
accessModeRef: { current: ApprovalMode };
|
|
51
53
|
setSearchParams: (fn: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void;
|
|
52
|
-
/** Advance the pending-dialog queue after the head was answered. */
|
|
53
|
-
dismissExtensionDialog: () => void;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export interface ChatTimelineActionsResult {
|
|
@@ -64,7 +64,6 @@ export interface ChatTimelineActionsResult {
|
|
|
64
64
|
stopGenerating: () => number;
|
|
65
65
|
handleThinkingLevelChange: (level: string) => void;
|
|
66
66
|
handleModelChange: (provider: string, modelId: string) => void;
|
|
67
|
-
closeExtensionDialog: () => void;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimelineActionsResult {
|
|
@@ -77,7 +76,8 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
77
76
|
sessionId,
|
|
78
77
|
appSettings,
|
|
79
78
|
messageQueue,
|
|
80
|
-
|
|
79
|
+
enqueueMessage,
|
|
80
|
+
removeMessage,
|
|
81
81
|
executeSend,
|
|
82
82
|
steerOmpAgent,
|
|
83
83
|
ompAgent,
|
|
@@ -91,7 +91,6 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
91
91
|
composerModelRef,
|
|
92
92
|
accessModeRef,
|
|
93
93
|
setSearchParams,
|
|
94
|
-
dismissExtensionDialog,
|
|
95
94
|
} = deps;
|
|
96
95
|
|
|
97
96
|
const handleSend = useCallback(async (attachments: Attachment[], options?: { steering?: boolean }) => {
|
|
@@ -127,41 +126,36 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
127
126
|
return;
|
|
128
127
|
}
|
|
129
128
|
const composerPick = composerModelRef.current;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
: null,
|
|
139
|
-
};
|
|
140
|
-
// Both modes: hold the follow-up in the client queue. The panel is the
|
|
141
|
-
// source of truth (editable, removable, survives reload via the
|
|
142
|
-
// session-state blob); delivery happens when the run ends — the
|
|
143
|
-
// auto-process effect sends the head item. For omp this replaces the
|
|
144
|
-
// old immediate `follow_up` RPC, whose server-side queue the client
|
|
145
|
-
// could never cancel (Stop kept executing it).
|
|
129
|
+
// Snapshot the composer's model/thinking plus the live access mode so
|
|
130
|
+
// server-side auto-delivery runs the item with exactly these settings.
|
|
131
|
+
const model = composerPick
|
|
132
|
+
? { ...composerPick, accessMode: accessModeRef.current }
|
|
133
|
+
: null;
|
|
134
|
+
// Both modes: the item is stored server-side (`queued_messages`) and the
|
|
135
|
+
// panel is a view of it. Delivery happens when the run ends — the
|
|
136
|
+
// wrapper's terminal `agent_end` claims the head and dispatches it.
|
|
146
137
|
setInputValue('');
|
|
147
|
-
|
|
138
|
+
enqueueMessage({ text: textToSend, attachments, model });
|
|
148
139
|
return;
|
|
149
140
|
}
|
|
150
141
|
|
|
151
142
|
setInputValue('');
|
|
152
143
|
executeSend(textToSend, attachments);
|
|
153
|
-
}, [inputValue, isGenerating, executeSend,
|
|
144
|
+
}, [inputValue, isGenerating, executeSend, enqueueMessage, isOmpSession, appSettings, steerOmpAgent, setInputValue, abortControllerRef, setGenerating, stopHoldRef, composerModelRef, accessModeRef]);
|
|
154
145
|
|
|
155
146
|
const handleEditQueueItem = useCallback((item: QueuedMessage) => {
|
|
156
|
-
|
|
147
|
+
// Lift the text into the composer and drop the row. If the user never
|
|
148
|
+
// re-submits, the delete (below) already removed it — the old flow had a
|
|
149
|
+
// dead window here where removing from the local list was the only edit.
|
|
157
150
|
setInputValue(item.text);
|
|
158
151
|
setInputAttachments(item.attachments);
|
|
159
|
-
|
|
152
|
+
removeMessage(item.id);
|
|
153
|
+
}, [removeMessage, setInputValue, setInputAttachments]);
|
|
160
154
|
|
|
161
155
|
const handleSendNowQueueItem = useCallback(async (item: QueuedMessage) => {
|
|
162
|
-
// Explicit delivery also lifts the Stop hold.
|
|
156
|
+
// Explicit delivery also lifts the Stop hold (mock path).
|
|
163
157
|
stopHoldRef.current = false;
|
|
164
|
-
|
|
158
|
+
removeMessage(item.id);
|
|
165
159
|
|
|
166
160
|
if (isGenerating) {
|
|
167
161
|
if (isOmpSession) {
|
|
@@ -177,7 +171,7 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
177
171
|
} else {
|
|
178
172
|
executeSend(item.text, item.attachments, { model: item.model });
|
|
179
173
|
}
|
|
180
|
-
}, [isGenerating, executeSend,
|
|
174
|
+
}, [isGenerating, executeSend, removeMessage, isOmpSession, steerOmpAgent, abortControllerRef, setGenerating, stopHoldRef]);
|
|
181
175
|
|
|
182
176
|
const handleUndo = useCallback(async (msgId: string, content?: string): Promise<boolean> => {
|
|
183
177
|
if (isGenerating) {
|
|
@@ -302,10 +296,6 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
302
296
|
void ompAgent.setModel(provider, modelId);
|
|
303
297
|
}, [isOmpSession, ompAgent, pendingComposerModelRef]);
|
|
304
298
|
|
|
305
|
-
const closeExtensionDialog = useCallback(() => {
|
|
306
|
-
dismissExtensionDialog();
|
|
307
|
-
}, [dismissExtensionDialog]);
|
|
308
|
-
|
|
309
299
|
return {
|
|
310
300
|
handleSend,
|
|
311
301
|
handleEditQueueItem,
|
|
@@ -316,6 +306,5 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
|
|
|
316
306
|
stopGenerating,
|
|
317
307
|
handleThinkingLevelChange,
|
|
318
308
|
handleModelChange,
|
|
319
|
-
closeExtensionDialog,
|
|
320
309
|
};
|
|
321
310
|
}
|
|
@@ -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
|
+
}
|