@kabmo-labs/kabmo 0.1.0 → 0.2.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 (79) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +285 -285
  3. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  4. package/dist/client/static/js/{index-enkbbffs.js → index-zsskckw7.js} +671 -671
  5. package/package.json +1 -1
  6. package/src/client/bootstrap.d.ts +8 -0
  7. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  8. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  9. package/src/client/components/common/header-controls/index.tsx +53 -4
  10. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  11. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  12. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  13. package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
  14. package/src/client/components/layout/session-sidebar/index.tsx +5 -0
  15. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  16. package/src/client/components/mobile/SessionSidebar.tsx +5 -0
  17. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  18. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
  20. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
  21. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  22. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  23. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  24. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  25. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  26. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  27. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  28. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  29. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  30. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  31. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  32. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  33. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  34. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  36. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  37. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  38. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  39. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  40. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  41. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  42. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  43. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  44. package/src/client/hooks/chat/timeline/index.ts +2 -1
  45. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  46. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  47. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  48. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  49. package/src/client/hooks/ui/media-query.ts +41 -0
  50. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  51. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  52. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  53. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  54. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  55. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  56. package/src/client/lib/native/back.test.ts +108 -0
  57. package/src/client/lib/native/back.ts +60 -0
  58. package/src/client/main.tsx +6 -0
  59. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  60. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  61. package/src/server/lib/fs/git-publish.test.ts +96 -0
  62. package/src/server/lib/fs/git-publish.ts +35 -3
  63. package/src/server/lib/fs/listing.test.ts +86 -0
  64. package/src/server/lib/fs/listing.ts +20 -6
  65. package/src/server/lib/updates/android-release.test.ts +92 -0
  66. package/src/server/lib/updates/android-release.ts +148 -0
  67. package/src/server/plugins/ssr.ts +9 -2
  68. package/src/server/routes/fs/read.ts +24 -9
  69. package/src/server/routes/sessions/batch-delete.test.ts +14 -11
  70. package/src/server/routes/updates/android.ts +22 -0
  71. package/src/server/routes/updates/index.ts +2 -0
  72. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  73. package/src/shared/lib/updates/shell-ua.ts +36 -0
  74. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  75. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  76. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  77. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  78. package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
  79. package/dist/client/static/js/index-yn17t04b.css +0 -1
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The search panel's clear (×) controls.
8
+ *
9
+ * Two properties matter and neither is visible from the state alone:
10
+ *
11
+ * - **The button lives INSIDE its field's box.** A control that empties an
12
+ * input belongs to that input; parked beside it, the panel reads as two
13
+ * unrelated controls and the × looks like it clears the panel.
14
+ * - **A cleared field keeps focus.** The reason to clear a query is to type a
15
+ * different one, so handing focus back to the browser is half the job.
16
+ *
17
+ * The button also only exists while the field holds text — an always-present ×
18
+ * is a control that can do nothing, and `aria-label` is what a screen reader
19
+ * announces, so both are asserted.
20
+ */
21
+
22
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
23
+ import { Window } from 'happy-dom';
24
+ import type { h as PreactH, render as PreactRender } from 'preact';
25
+ import type { act as PreactAct } from 'preact/test-utils';
26
+ import type { SearchPanelBody as SearchPanelBodyComponent } from '@/client/components/workspace/search-panel/Body';
27
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
28
+
29
+ let SearchPanelBody: typeof SearchPanelBodyComponent;
30
+ let h: typeof PreactH;
31
+ let render: typeof PreactRender;
32
+ let act: typeof PreactAct;
33
+
34
+ let container: HTMLElement;
35
+ let query = '';
36
+ let replaceQuery = '';
37
+
38
+ function mount() {
39
+ const noop = () => {};
40
+ act(() => {
41
+ render(
42
+ h(SearchPanelBody, {
43
+ className: '',
44
+ query,
45
+ setQuery: (value: string) => { query = value; rerender(); },
46
+ replaceQuery,
47
+ setReplaceQuery: (value: string) => { replaceQuery = value; rerender(); },
48
+ matchCase: false,
49
+ setMatchCase: noop,
50
+ wholeWord: false,
51
+ setWholeWord: noop,
52
+ useRegex: false,
53
+ setUseRegex: noop,
54
+ includeFiles: '',
55
+ setIncludeFiles: noop,
56
+ showIncludeField: false,
57
+ setShowIncludeField: noop,
58
+ showMenu: false,
59
+ setShowMenu: noop,
60
+ menuRef: { current: null },
61
+ activeRepo: '.',
62
+ setActiveRepo: noop,
63
+ repos: ['.'],
64
+ reposScanning: false,
65
+ rescanRepos: noop,
66
+ results: [],
67
+ isSearching: false,
68
+ truncated: false,
69
+ error: null,
70
+ handleReplace: noop,
71
+ replaceBusy: false,
72
+ }),
73
+ container,
74
+ );
75
+ });
76
+ }
77
+
78
+ function rerender() {
79
+ mount();
80
+ }
81
+
82
+ function button(label: string): HTMLButtonElement | null {
83
+ return container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
84
+ }
85
+
86
+ beforeAll(async () => {
87
+ installDomGlobals(new Window({ url: 'http://localhost' }));
88
+ ({ SearchPanelBody } = await import('@/client/components/workspace/search-panel/Body'));
89
+ ({ h, render } = await import('preact'));
90
+ ({ act } = await import('preact/test-utils'));
91
+ });
92
+
93
+ afterAll(() => restoreDomGlobals());
94
+
95
+ afterEach(() => {
96
+ if (container) render(null, container);
97
+ container?.remove();
98
+ query = '';
99
+ replaceQuery = '';
100
+ });
101
+
102
+ describe('search panel clear controls', () => {
103
+ test('no clear button while a field is empty', () => {
104
+ container = document.createElement('div');
105
+ document.body.appendChild(container);
106
+ mount();
107
+
108
+ expect(button('Clear search')).toBeNull();
109
+ expect(button('Clear replace')).toBeNull();
110
+ });
111
+
112
+ test('the search clear button sits inside the search field and empties it', () => {
113
+ query = 'kabmo';
114
+ container = document.createElement('div');
115
+ document.body.appendChild(container);
116
+ mount();
117
+
118
+ const clear = button('Clear search');
119
+ expect(clear).not.toBeNull();
120
+ // Inside the field: the input and the button share a parent box.
121
+ const input = container.querySelector<HTMLInputElement>('input[placeholder="Search"]');
122
+ expect(input).not.toBeNull();
123
+ expect(clear!.parentElement).toBe(input!.parentElement);
124
+
125
+ act(() => clear!.click());
126
+ expect(query).toBe('');
127
+ expect(button('Clear search')).toBeNull();
128
+ expect(document.activeElement).toBe(input!);
129
+ });
130
+
131
+ test('the replace clear button sits inside the replace field and empties it', () => {
132
+ query = 'kabmo';
133
+ replaceQuery = 'KABMO';
134
+ container = document.createElement('div');
135
+ document.body.appendChild(container);
136
+ mount();
137
+
138
+ const clear = button('Clear replace');
139
+ expect(clear).not.toBeNull();
140
+ const input = container.querySelector<HTMLInputElement>('input[placeholder="Replace"]');
141
+ expect(input).not.toBeNull();
142
+ expect(clear!.parentElement).toBe(input!.parentElement);
143
+
144
+ act(() => clear!.click());
145
+ expect(replaceQuery).toBe('');
146
+ expect(button('Clear replace')).toBeNull();
147
+ expect(document.activeElement).toBe(input!);
148
+ });
149
+ });
@@ -1,5 +1,5 @@
1
- import { useCallback, useMemo } from 'preact/hooks';
2
- import { CaseSensitive, Check, MoreHorizontal, Regex, ReplaceAll, Search, WholeWord } from 'lucide-preact';
1
+ import { useCallback, useMemo, useRef } from 'preact/hooks';
2
+ import { CaseSensitive, Check, MoreHorizontal, Regex, ReplaceAll, Search, WholeWord, X } from 'lucide-preact';
3
3
  import type { RefObject } from 'preact';
4
4
  import { GitRepoPicker } from '@/client/components/workspace/repo-picker';
5
5
  import { SearchResults } from '@/client/components/workspace/search-panel/Results';
@@ -87,6 +87,24 @@ export function SearchPanelBody({
87
87
  }: SearchPanelBodyProps) {
88
88
  const groups = useMemo(() => groupSearchResults(results), [results]);
89
89
 
90
+ /**
91
+ * The clear (×) buttons live INSIDE their field, and focus returns to it
92
+ * after clearing: the point of the control is to retype the query, so a
93
+ * cleared field the user has to click again is half the job. The buttons sit
94
+ * in the field's own box rather than beside it for the same reason — a
95
+ * control that clears the input belongs to the input.
96
+ */
97
+ const queryInputRef = useRef<HTMLInputElement>(null);
98
+ const replaceInputRef = useRef<HTMLInputElement>(null);
99
+ const clearQuery = useCallback(() => {
100
+ setQuery('');
101
+ queryInputRef.current?.focus();
102
+ }, [setQuery]);
103
+ const clearReplace = useCallback(() => {
104
+ setReplaceQuery('');
105
+ replaceInputRef.current?.focus();
106
+ }, [setReplaceQuery]);
107
+
90
108
  // The same flags the search ran with, so the editor's own matcher paints the
91
109
  // identical set of occurrences once the file opens.
92
110
  const options: FindOptions = useMemo(() => ({ matchCase, wholeWord, isRegex: useRegex }), [matchCase, wholeWord, useRegex]);
@@ -159,12 +177,24 @@ export function SearchPanelBody({
159
177
  <div className="flex items-center border border-ink/20 rounded bg-paper focus-within:border-ink/40 focus-within:ring-1 focus-within:ring-ink/10 transition-all">
160
178
  <Search size={14} className="ml-2 text-ink/40 flex-shrink-0" />
161
179
  <input
180
+ ref={queryInputRef}
162
181
  type="text"
163
182
  placeholder="Search"
164
183
  value={query}
165
184
  onChange={(e) => setQuery(e.currentTarget.value)}
166
185
  className="w-full bg-transparent border-none text-xs px-2 py-1.5 focus:outline-none text-ink placeholder-ink/30 min-w-0"
167
186
  />
187
+ {query && (
188
+ <button
189
+ type="button"
190
+ onClick={clearQuery}
191
+ title="Clear search"
192
+ aria-label="Clear search"
193
+ className="p-1 mr-0.5 rounded text-ink/40 hover:text-ink hover:bg-ink/5 flex-shrink-0"
194
+ >
195
+ <X size={12} />
196
+ </button>
197
+ )}
168
198
  <div className="flex items-center space-x-0.5 pr-1 text-ink/40 flex-shrink-0">
169
199
  <button
170
200
  onClick={() => setMatchCase(!matchCase)}
@@ -192,13 +222,27 @@ export function SearchPanelBody({
192
222
  </div>
193
223
 
194
224
  <div className="relative flex items-center space-x-1">
195
- <input
196
- type="text"
197
- placeholder="Replace"
198
- value={replaceQuery}
199
- onChange={(e) => setReplaceQuery(e.currentTarget.value)}
200
- className="flex-1 min-w-0 bg-paper border border-ink/20 rounded text-xs px-2 py-1.5 focus:outline-none focus:border-ink/40 focus:ring-1 focus:ring-ink/10 transition-all text-ink placeholder-ink/30"
201
- />
225
+ <div className="flex flex-1 min-w-0 items-center border border-ink/20 rounded bg-paper focus-within:border-ink/40 focus-within:ring-1 focus-within:ring-ink/10 transition-all">
226
+ <input
227
+ ref={replaceInputRef}
228
+ type="text"
229
+ placeholder="Replace"
230
+ value={replaceQuery}
231
+ onChange={(e) => setReplaceQuery(e.currentTarget.value)}
232
+ className="flex-1 min-w-0 bg-transparent border-none text-xs px-2 py-1.5 focus:outline-none text-ink placeholder-ink/30"
233
+ />
234
+ {replaceQuery && (
235
+ <button
236
+ type="button"
237
+ onClick={clearReplace}
238
+ title="Clear replace"
239
+ aria-label="Clear replace"
240
+ className="p-1 mr-0.5 rounded text-ink/40 hover:text-ink hover:bg-ink/5 flex-shrink-0"
241
+ >
242
+ <X size={12} />
243
+ </button>
244
+ )}
245
+ </div>
202
246
  <button
203
247
  onClick={() => handleReplace()}
204
248
  disabled={results.length === 0 || replaceBusy}
@@ -63,7 +63,7 @@ describe('splitAskFrames with a grouped ask frame', () => {
63
63
  expect(split.modalRequest).toBeNull();
64
64
  });
65
65
 
66
- test('an unowned grouped frame stays for the modal', () => {
66
+ test('an unowned grouped frame becomes an inline orphan, never a modal', () => {
67
67
  const stranger: ExtensionUiDialogRequest = {
68
68
  ...grouped,
69
69
  id: 'frame-2',
@@ -71,6 +71,50 @@ describe('splitAskFrames with a grouped ask frame', () => {
71
71
  };
72
72
  const split = splitAskFrames([message()], [stranger], true);
73
73
  expect(split.framesByTool.get('call-ask')).toBeUndefined();
74
- expect(split.modalRequest).toBe(stranger);
74
+ // The timeline draws it (`OrphanAsks`); the modal has no `ask` body, so a
75
+ // grouped frame handed to it rendered an empty panel.
76
+ expect(split.orphanAsks).toEqual([stranger]);
77
+ expect(split.modalRequest).toBeNull();
78
+ });
79
+
80
+ test('a grouped frame whose questions carry no ids still claims its card', () => {
81
+ // Measured on 2 of 161 real `ask` calls: omp's schema requires an id, but
82
+ // a call written by an older build carried none. The id walk then matched
83
+ // nothing and the frame fell through to the MODAL.
84
+ const tool = askTool();
85
+ tool.input = {
86
+ questions: [
87
+ { id: '', question: 'Pick colors', options: [{ label: 'red' }], multi: true },
88
+ { id: '', question: 'Pick a size', options: [{ label: 's' }] },
89
+ ],
90
+ };
91
+ const unkeyed: ExtensionUiDialogRequest = {
92
+ ...grouped,
93
+ id: 'frame-3',
94
+ questions: [
95
+ { id: '', question: 'Pick colors', options: [{ label: 'red' }], multi: true },
96
+ { id: '', question: 'Pick a size', options: [{ label: 's' }] },
97
+ ],
98
+ };
99
+ const history = { id: 'm1', role: 'ai', content: '', toolCalls: [tool] } as unknown as ChatMessageData;
100
+
101
+ const split = splitAskFrames([history], [unkeyed], true);
102
+ expect(split.framesByTool.get('call-ask')?.length).toBe(2);
103
+ expect(split.orphanAsks).toEqual([]);
104
+ expect(split.modalRequest).toBeNull();
105
+ });
106
+
107
+ test('a grouped frame matches its card by question text when ids differ', () => {
108
+ const retitled: ExtensionUiDialogRequest = {
109
+ ...grouped,
110
+ id: 'frame-4',
111
+ questions: [
112
+ { id: 'omp-a', question: 'Pick colors', options: [{ label: 'red' }], multi: true },
113
+ { id: 'omp-b', question: 'Pick a size', options: [{ label: 's' }] },
114
+ ],
115
+ };
116
+ const split = splitAskFrames([message()], [retitled], true);
117
+ expect(split.framesByTool.get('call-ask')?.length).toBe(2);
118
+ expect(split.orphanAsks).toEqual([]);
75
119
  });
76
120
  });
@@ -65,7 +65,7 @@ describe('splitAskFrames', () => {
65
65
  const gate = confirmFrame('g1', 'Run bash?');
66
66
 
67
67
  // The regression: the frame has been replayed, its tool call has not.
68
- expect(splitAskFrames([], [question], false)).toEqual({ framesByTool: new Map(), modalRequest: null });
68
+ expect(splitAskFrames([], [question], false)).toEqual({ framesByTool: new Map(), orphanAsks: [], modalRequest: null });
69
69
  // The hold is uniform — a gate cannot be told from an ask before history
70
70
  // either, and it is re-decided the moment the history settles.
71
71
  expect(splitAskFrames([], [gate], false).modalRequest).toBeNull();
@@ -12,23 +12,31 @@
12
12
  * (approval gates under `always-ask`, extension pickers) has no card of its own
13
13
  * and keeps the modal.
14
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.
15
+ * An `ask` frame is therefore NEVER modal material, in any state. It is either
16
+ * claimed by the card that raised it, or returned as an ORPHAN the timeline
17
+ * draws inline: a question whose tool call this client never saw (a subagent's,
18
+ * an extension's own `askDialog`, a tool call outside the loaded window) still
19
+ * blocks the agent, and the one place it must not appear is a dialog over the
20
+ * conversation. That is what an empty modal was — `ExtensionDialog` has no
21
+ * `ask` body, so an orphaned frame painted a panel with no question in it and a
22
+ * single button whose click omp rejected ("Ask dialog response must carry N
23
+ * answers"), leaving the run parked.
21
24
  *
22
- * That fallback must wait for the session's own history, though. Ownership is
23
- * decided against the ask tool calls in `messages`, and on session open the
25
+ * The claim walks two keys, because omp gives the frame less than the tool call
26
+ * does: the question IDS when both carry them, and the question TEXT (what
27
+ * omp's own per-question loop matches on). A frame that matches neither is an
28
+ * orphan, not a modal — and deliberately not "the only open ask", which would
29
+ * steal a ratchet approval or an extension's own `askDialog` into a tool card
30
+ * that did not raise it.
31
+ *
32
+ * The modal is also held until the session's own history has loaded. Ownership
33
+ * is decided against the ask tool calls in `messages`, and on session open the
24
34
  * pending dialogs are replayed from the agent-state probe — which answers out
25
- * of the wrapper's local flags — long before the JSONL fetch lands. Every
26
- * replayed frame therefore looks unowned for that window, and rendering one as
27
- * a modal painted the question on screen and yanked it away the instant the
28
- * timeline arrived and its card claimed it: the flash seen when switching to a
29
- * session parked on an ask. `historyLoaded` holds the modal — never the card
30
- * claim — until there is a timeline to decide against; a frame no card claims
31
- * after that still gets its modal.
35
+ * of the wrapper's local flags — long before the JSONL fetch lands. Rendering a
36
+ * replayed APPROVAL as a modal in that window painted it on screen and yanked
37
+ * it away the instant the timeline arrived and its card claimed it: the flash
38
+ * seen when switching to a session parked on an ask. `historyLoaded` holds the
39
+ * modal — never the card claim — until there is a timeline to decide against.
32
40
  */
33
41
 
34
42
  import { createContext, useContext } from 'preact/compat';
@@ -40,17 +48,21 @@ import type { ExtensionDialogResponse } from '@/client/components/workspace/chat
40
48
  export interface AskFramesSplit {
41
49
  /** The ask's dialogs, bucketed per question, keyed by ask tool-call id. */
42
50
  framesByTool: Map<string, ExtensionUiDialogRequest[][]>;
43
- /** Oldest request no ask card owns — the one the modal renders, if any. */
51
+ /** Ask dialogs no card can own — the timeline draws them inline. */
52
+ orphanAsks: ExtensionUiDialogRequest[];
53
+ /** Oldest non-ask request nothing claimed — the one the modal renders. */
44
54
  modalRequest: ExtensionUiDialogRequest | null;
45
55
  }
46
56
 
47
57
  export interface AskFramesHandle {
48
58
  framesByTool: AskFramesSplit['framesByTool'];
59
+ /** Ask dialogs with no tool card to render them (see `orphanAsks`). */
60
+ orphanAsks: AskFramesSplit['orphanAsks'];
49
61
  /** Answer one ask dialog and drop it from the pending queue. */
50
62
  respond: (request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => void;
51
63
  }
52
64
 
53
- const EMPTY: AskFramesHandle = { framesByTool: new Map(), respond: () => {} };
65
+ const EMPTY: AskFramesHandle = { framesByTool: new Map(), orphanAsks: [], respond: () => {} };
54
66
 
55
67
  export const AskFramesContext = createContext<AskFramesHandle>(EMPTY);
56
68
 
@@ -67,7 +79,7 @@ export function splitAskFrames(
67
79
  historyLoaded: boolean,
68
80
  ): AskFramesSplit {
69
81
  const framesByTool = new Map<string, ExtensionUiDialogRequest[][]>();
70
- if (pending.length === 0) return { framesByTool, modalRequest: null };
82
+ if (pending.length === 0) return { framesByTool, orphanAsks: [], modalRequest: null };
71
83
 
72
84
  // Match against the asks that can still own a dialog: a reloaded session
73
85
  // reads its tool calls back from the JSONL, where a call still parked on a
@@ -95,25 +107,61 @@ export function splitAskFrames(
95
107
 
96
108
  const owned = new Map<string, ExtensionUiDialogRequest[]>();
97
109
  const claimed = new Set<ExtensionUiDialogRequest>();
110
+ const orphanAsks: ExtensionUiDialogRequest[] = [];
98
111
  let lastOwner: string | null = null;
99
112
 
100
113
  for (const request of pending) {
114
+ // An `ask` frame is never modal material, whatever it carries. A malformed
115
+ // one (no `questions`) has nothing for a card to draw either, but handing it
116
+ // to the title/lastOwner walk below would file it under an unrelated ask's
117
+ // question — so it is orphaned like any other cardless ask.
118
+ const isAskFrame = request.method === 'ask';
101
119
  // A GROUPED ask frame (`method:"ask"`, sent after `set_ask_dialog`) carries
102
120
  // every question of one call and no `title` at all, so the title walk below
103
121
  // can never place it. Its questions carry omp's own ids, which is the
104
122
  // exact key the tool call's arguments use — match on those.
105
- const grouped = Array.isArray(request.questions) && request.questions.length > 0 ? request.questions : null;
123
+ const grouped = isAskFrame && Array.isArray(request.questions) && request.questions.length > 0
124
+ ? request.questions
125
+ : null;
126
+ if (isAskFrame && !grouped) {
127
+ if (historyLoaded) orphanAsks.push(request);
128
+ continue;
129
+ }
106
130
  if (grouped) {
107
131
  const ids = new Set(grouped.map((question) => question.id).filter(Boolean));
108
- const owner = [...openAsks].reverse().find((candidate) =>
132
+ let groupedOwner = [...openAsks].reverse().find((candidate) =>
109
133
  candidate.questions.some((question) => question.id && ids.has(question.id)),
110
134
  );
111
- if (owner) {
135
+ // The id walk is the exact key, but it is not the only one omp gives us:
136
+ // a call whose questions carry no `id` (measured on 2 of 161 real `ask`
137
+ // calls) yields an empty id set, and the frame then matched nothing and
138
+ // fell through to the MODAL — an ask dialog shown outside the timeline,
139
+ // which is the one place it must never be. The question TEXT is what
140
+ // omp's own per-question loop matches on, so it is the fallback here.
141
+ if (!groupedOwner) {
142
+ const titles = new Set(grouped.map((question) => normalizeAskText(question.question)).filter(Boolean));
143
+ groupedOwner = [...openAsks].reverse().find((candidate) =>
144
+ candidate.questions.some((question) => titles.has(normalizeAskText(question.question))),
145
+ );
146
+ }
147
+ if (groupedOwner) {
112
148
  claimed.add(request);
113
- lastOwner = owner.toolId;
114
- owned.set(owner.toolId, [...(owned.get(owner.toolId) ?? []), request]);
115
- continue;
149
+ lastOwner = groupedOwner.toolId;
150
+ owned.set(groupedOwner.toolId, [...(owned.get(groupedOwner.toolId) ?? []), request]);
116
151
  }
152
+ // A grouped ask is NEVER modal material: with no card to claim it, it is
153
+ // an orphan the timeline draws inline (see the module note). It must not
154
+ // fall through to the title/lastOwner walk either — that walk exists for
155
+ // the per-question `select` loop, and handing a grouped frame to it would
156
+ // file one dialog under a single question.
157
+ //
158
+ // The orphan row waits for the history for the same reason the modal
159
+ // does: a frame replayed on session open looks cardless until the JSONL
160
+ // lands, so drawing it now would paint the question at the tail of the
161
+ // timeline and then move it onto its card. The frame stays queued, so
162
+ // nothing is lost — the split simply re-runs.
163
+ if (!groupedOwner && historyLoaded) orphanAsks.push(request);
164
+ continue;
117
165
  }
118
166
 
119
167
  const title = typeof request.title === 'string' ? normalizeAskText(request.title) : '';
@@ -164,9 +212,11 @@ export function splitAskFrames(
164
212
  }
165
213
 
166
214
  // The frame stays queued either way — only the modal waits for the history,
167
- // so nothing is dropped and nothing flashes.
168
- return {
169
- framesByTool,
170
- modalRequest: historyLoaded ? pending.find((request) => !claimed.has(request)) ?? null : null,
171
- };
215
+ // so nothing is dropped and nothing flashes. An `ask` frame is excluded here
216
+ // in BOTH states: it is answered by a card or by the orphan row, and the
217
+ // modal has no body for it.
218
+ const modalRequest = historyLoaded
219
+ ? pending.find((request) => !claimed.has(request) && request.method !== 'ask') ?? null
220
+ : null;
221
+ return { framesByTool, orphanAsks, modalRequest };
172
222
  }
@@ -49,7 +49,7 @@ export function useChatTimeline({
49
49
  // without re-creating it (useSessionLoad is defined below the scroll hook).
50
50
  const loadOlderRef = useRef<() => void>(() => {});
51
51
 
52
- const { scrollRef, contentRef, showScrollBottom, handleScroll, scrollToBottom, jumpToBottom, followRef, jumpActiveRef, jumpCountRef } = useChatTimelineScroll({
52
+ const { scrollRef, contentRef, showScrollBottom, readingBack, handleScroll, scrollToBottom, jumpToBottom, followRef, jumpActiveRef, jumpCountRef } = useChatTimelineScroll({
53
53
  onScrollTop: () => loadOlderRef.current(),
54
54
  });
55
55
 
@@ -324,6 +324,7 @@ export function useChatTimeline({
324
324
  scrollRef,
325
325
  contentRef,
326
326
  showScrollBottom,
327
+ readingBack,
327
328
  handleScroll,
328
329
  scrollToBottom,
329
330
  jumpToBottom,