@kabmo-labs/kabmo 0.1.0 → 0.3.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/index.js +285 -285
- package/dist/client/static/js/{index-enkbbffs.js → index-8x5j8zrr.js} +671 -671
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/package.json +1 -1
- package/src/client/bootstrap.d.ts +17 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/layout/session-sidebar/Footer.tsx +24 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
- package/src/client/components/layout/session-sidebar/index.tsx +8 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +29 -1
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/data/settings/omp-schema.json +89 -4
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +112 -0
- package/src/client/hooks/ui/updates/android-download.ts +143 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/updates/android-release.test.ts +138 -0
- package/src/server/lib/updates/android-release.ts +183 -0
- package/src/server/plugins/ssr.ts +14 -2
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +14 -11
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +121 -0
- package/src/shared/lib/updates/shell-ua.ts +56 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
- 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
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"source": "@oh-my-pi/pi-coding-agent 10 tabs /
|
|
2
|
+
"source": "@oh-my-pi/pi-coding-agent 10 tabs / 559 settings",
|
|
3
3
|
"tabs": [
|
|
4
4
|
{
|
|
5
5
|
"id": "appearance",
|
|
@@ -507,6 +507,16 @@
|
|
|
507
507
|
]
|
|
508
508
|
}
|
|
509
509
|
},
|
|
510
|
+
"tui.completeScrollback": {
|
|
511
|
+
"type": "boolean",
|
|
512
|
+
"default": false,
|
|
513
|
+
"ui": {
|
|
514
|
+
"tab": "appearance",
|
|
515
|
+
"group": "Display",
|
|
516
|
+
"label": "Complete Scrollback",
|
|
517
|
+
"description": "Send transcript rows pushed off screen by a growing HUD or dialog straight to scrollback; when it shrinks, blank rows sit between the transcript and the input until new output fills them"
|
|
518
|
+
}
|
|
519
|
+
},
|
|
510
520
|
"terminal.showProgress": {
|
|
511
521
|
"type": "boolean",
|
|
512
522
|
"default": false,
|
|
@@ -3865,6 +3875,39 @@
|
|
|
3865
3875
|
"condition": "advisorEnabled"
|
|
3866
3876
|
}
|
|
3867
3877
|
},
|
|
3878
|
+
"advisor.continueSeverity": {
|
|
3879
|
+
"type": "enum",
|
|
3880
|
+
"default": "concern",
|
|
3881
|
+
"values": [
|
|
3882
|
+
"nit",
|
|
3883
|
+
"concern",
|
|
3884
|
+
"blocker"
|
|
3885
|
+
],
|
|
3886
|
+
"ui": {
|
|
3887
|
+
"tab": "model",
|
|
3888
|
+
"group": "Advisor",
|
|
3889
|
+
"label": "Advisor Continue Severity",
|
|
3890
|
+
"description": "Minimum severity of an advisor note that wakes the agent after it finished with a final answer. Lower-severity notes stay as a card at the end of the transcript and reach the agent with your next message.",
|
|
3891
|
+
"options": [
|
|
3892
|
+
{
|
|
3893
|
+
"value": "nit",
|
|
3894
|
+
"label": "Nit",
|
|
3895
|
+
"description": "Every note continues the run."
|
|
3896
|
+
},
|
|
3897
|
+
{
|
|
3898
|
+
"value": "concern",
|
|
3899
|
+
"label": "Concern",
|
|
3900
|
+
"description": "Concerns and blockers continue the run. Default."
|
|
3901
|
+
},
|
|
3902
|
+
{
|
|
3903
|
+
"value": "blocker",
|
|
3904
|
+
"label": "Blocker",
|
|
3905
|
+
"description": "Only blockers continue the run."
|
|
3906
|
+
}
|
|
3907
|
+
],
|
|
3908
|
+
"condition": "advisorEnabled"
|
|
3909
|
+
}
|
|
3910
|
+
},
|
|
3868
3911
|
"advisor.reviewMode": {
|
|
3869
3912
|
"type": "enum",
|
|
3870
3913
|
"default": "turn",
|
|
@@ -4062,7 +4105,7 @@
|
|
|
4062
4105
|
{
|
|
4063
4106
|
"value": "remote",
|
|
4064
4107
|
"label": "Server compaction",
|
|
4065
|
-
"description": "Use provider-native server compaction (OpenAI Responses compact, Anthropic compaction beta) when the active route supports it"
|
|
4108
|
+
"description": "Use provider-native server compaction (OpenAI Responses compact, Anthropic compaction beta, including on Bedrock) when the active route supports it"
|
|
4066
4109
|
},
|
|
4067
4110
|
{
|
|
4068
4111
|
"value": "snapcompact",
|
|
@@ -4312,7 +4355,7 @@
|
|
|
4312
4355
|
"tab": "context",
|
|
4313
4356
|
"group": "Compaction",
|
|
4314
4357
|
"label": "Idle Compaction Delay",
|
|
4315
|
-
"description": "Seconds to wait while idle before compacting",
|
|
4358
|
+
"description": "Seconds to wait while idle before compacting; with a 1-hour prompt cache, compaction instead runs just before the cache expires",
|
|
4316
4359
|
"options": [
|
|
4317
4360
|
{
|
|
4318
4361
|
"value": "60",
|
|
@@ -5811,6 +5854,7 @@
|
|
|
5811
5854
|
"default": "yolo",
|
|
5812
5855
|
"values": [
|
|
5813
5856
|
"always-ask",
|
|
5857
|
+
"always-ask-cached",
|
|
5814
5858
|
"write",
|
|
5815
5859
|
"yolo"
|
|
5816
5860
|
],
|
|
@@ -5818,13 +5862,18 @@
|
|
|
5818
5862
|
"tab": "interaction",
|
|
5819
5863
|
"group": "Approvals",
|
|
5820
5864
|
"label": "Tool Approval",
|
|
5821
|
-
"description": "Default approval behavior for tool calls. 'Always ask' auto-approves read-only tools only. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves all tiers; user policy may still prompt or block.",
|
|
5865
|
+
"description": "Default approval behavior for tool calls. 'Always ask' auto-approves read-only tools only. 'Ask and remember' also asks before file reads, and can remember a file or folder. 'Write' auto-approves read and workspace-write tools. 'Yolo' auto-approves all tiers; user policy may still prompt or block.",
|
|
5822
5866
|
"options": [
|
|
5823
5867
|
{
|
|
5824
5868
|
"value": "always-ask",
|
|
5825
5869
|
"label": "Always ask",
|
|
5826
5870
|
"description": "Auto-approve read-only tools; require confirmation for write and exec tools."
|
|
5827
5871
|
},
|
|
5872
|
+
{
|
|
5873
|
+
"value": "always-ask-cached",
|
|
5874
|
+
"label": "Ask and remember",
|
|
5875
|
+
"description": "Ask before reading or writing a file, with answers that remember the file or its folder for this session or every session; require confirmation for exec tools."
|
|
5876
|
+
},
|
|
5828
5877
|
{
|
|
5829
5878
|
"value": "write",
|
|
5830
5879
|
"label": "Write",
|
|
@@ -6199,6 +6248,32 @@
|
|
|
6199
6248
|
"description": "Maximum screenshot height in pixels"
|
|
6200
6249
|
}
|
|
6201
6250
|
},
|
|
6251
|
+
"computer.backgroundFallback": {
|
|
6252
|
+
"type": "enum",
|
|
6253
|
+
"default": "takeover",
|
|
6254
|
+
"values": [
|
|
6255
|
+
"takeover",
|
|
6256
|
+
"refuse"
|
|
6257
|
+
],
|
|
6258
|
+
"ui": {
|
|
6259
|
+
"tab": "tools",
|
|
6260
|
+
"group": "Computer",
|
|
6261
|
+
"label": "Computer Background Fallback",
|
|
6262
|
+
"description": "What window input does when its background route is unavailable: rerun it in takeover and return focus to your app (macOS; elsewhere plain takeover), or refuse, which also keeps the agent from taking the foreground or opening macOS menus itself",
|
|
6263
|
+
"options": [
|
|
6264
|
+
{
|
|
6265
|
+
"value": "takeover",
|
|
6266
|
+
"label": "Takeover",
|
|
6267
|
+
"description": "Briefly bring the target forward for that one call, then return focus to your app (on macOS even if you kept typing)"
|
|
6268
|
+
},
|
|
6269
|
+
{
|
|
6270
|
+
"value": "refuse",
|
|
6271
|
+
"label": "Refuse",
|
|
6272
|
+
"description": "Fail the call; nothing takes over, and the agent's own takeover, raise, activating open, desktop input and macOS menus (context menus, popup and menu buttons; they take your keyboard while open) are refused too"
|
|
6273
|
+
}
|
|
6274
|
+
]
|
|
6275
|
+
}
|
|
6276
|
+
},
|
|
6202
6277
|
"images.questionTimeoutMs": {
|
|
6203
6278
|
"type": "number",
|
|
6204
6279
|
"default": 300000,
|
|
@@ -6435,6 +6510,16 @@
|
|
|
6435
6510
|
"description": "Enable async bash commands and background task execution"
|
|
6436
6511
|
}
|
|
6437
6512
|
},
|
|
6513
|
+
"async.widgets": {
|
|
6514
|
+
"type": "boolean",
|
|
6515
|
+
"default": true,
|
|
6516
|
+
"ui": {
|
|
6517
|
+
"tab": "tools",
|
|
6518
|
+
"group": "Execution",
|
|
6519
|
+
"label": "Live Job Widgets",
|
|
6520
|
+
"description": "Draw a live widget for each running background job (charts, bars, status grids derived from its output) in the wait card, the jobs pill and the jobs sheet. The `widget` model role (default: `smol`) writes one spec per job; updates need no further model calls"
|
|
6521
|
+
}
|
|
6522
|
+
},
|
|
6438
6523
|
"tools.xdev": {
|
|
6439
6524
|
"type": "boolean",
|
|
6440
6525
|
"default": true,
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
26
|
-
* replayed
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
132
|
+
let groupedOwner = [...openAsks].reverse().find((candidate) =>
|
|
109
133
|
candidate.questions.some((question) => question.id && ids.has(question.id)),
|
|
110
134
|
);
|
|
111
|
-
|
|
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 =
|
|
114
|
-
owned.set(
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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,
|