@kabmo-labs/kabmo 0.0.1 → 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.
- package/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/bootstrap.d.ts +8 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -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/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
- package/src/client/components/layout/session-sidebar/index.tsx +92 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +83 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
- 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/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 +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -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/client/tailwind.css +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/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/plugins/ssr.ts +9 -2
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +142 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -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 +84 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
|
@@ -54,6 +54,7 @@ export function ChatInput({
|
|
|
54
54
|
deferredComposerPickRef = NO_PENDING_PICK,
|
|
55
55
|
chatRunning,
|
|
56
56
|
expandable = false,
|
|
57
|
+
shrinkWhileReading = false,
|
|
57
58
|
}: ChatInputProps) {
|
|
58
59
|
const [internalAttachments, setInternalAttachments] = useState<Attachment[]>([]);
|
|
59
60
|
// The Goal modal is owned here, not by the toolbar: the button is a toggle,
|
|
@@ -64,6 +65,10 @@ export function ChatInput({
|
|
|
64
65
|
// layout preference for one draft, not a setting, and the URL is what carries
|
|
65
66
|
// the session it belongs to.
|
|
66
67
|
const [expanded, setExpanded] = useState(false);
|
|
68
|
+
// Whether anything inside the card has focus. A compacted composer restores
|
|
69
|
+
// itself the moment the user reaches for it: shrinking the box under a finger
|
|
70
|
+
// that is already typing is the one way this can be wrong.
|
|
71
|
+
const [composerFocused, setComposerFocused] = useState(false);
|
|
67
72
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
68
73
|
|
|
69
74
|
const { selectedModel, setSelectedModel, thinkingLevels, currentThinking, selectThinking } = useModelSelection({
|
|
@@ -137,7 +142,13 @@ export function ChatInput({
|
|
|
137
142
|
// Enter, so the covered toolbar would be the only way to send). Docked, the
|
|
138
143
|
// card is inside the chat's own column and the measurement is unused.
|
|
139
144
|
const keyboardInset = useKeyboardInset(cardRef, expandable && expanded);
|
|
140
|
-
|
|
145
|
+
// A phone gives its height back to the transcript while the reader is
|
|
146
|
+
// scrolling back — but only when there is nothing to lose: a draft means the
|
|
147
|
+
// user is writing, and the box must stay the size it was. A focused composer
|
|
148
|
+
// is the same rule from the other side (the tap that re-opens it must not be
|
|
149
|
+
// undone by the scroll position it was tapped from).
|
|
150
|
+
const compact = shrinkWhileReading && !composerFocused && !value.trim() && attachments.length === 0;
|
|
151
|
+
const layout = composerExpansion({ variant, expanded, isDragging, keyboardInset, compact });
|
|
141
152
|
|
|
142
153
|
// Escape returns to the docked composer. Bound to the document rather than to
|
|
143
154
|
// the textarea so it works while the picker has focus — the picker consumes
|
|
@@ -182,6 +193,13 @@ export function ChatInput({
|
|
|
182
193
|
ref={cardRef}
|
|
183
194
|
className={`@container transition-colors flex flex-col ${layout.cardPositionClass} ${layout.cardClass} ${layout.cardLayoutClass} ${className}`}
|
|
184
195
|
style={layout.style}
|
|
196
|
+
// focusin/focusout BUBBLE, unlike focus/blur: the card has to learn that
|
|
197
|
+
// the textarea inside it was focused, and a capture-phase `onFocus` on
|
|
198
|
+
// this element is not the same question (it fires for the card's own
|
|
199
|
+
// focus, which never happens). Preact maps `onFocusIn`/`onFocusOut` to
|
|
200
|
+
// the native bubble-phase events.
|
|
201
|
+
onFocusIn={() => setComposerFocused(true)}
|
|
202
|
+
onFocusOut={() => setComposerFocused(false)}
|
|
185
203
|
{...dropProps}
|
|
186
204
|
>
|
|
187
205
|
<DropOverlay visible={isDragging} />
|
|
@@ -212,7 +230,7 @@ export function ChatInput({
|
|
|
212
230
|
|
|
213
231
|
{/* Refused plan/goal command notice. */}
|
|
214
232
|
{(notice || modes?.error) && (
|
|
215
|
-
<div className="flex items-start gap-1.5
|
|
233
|
+
<div className="flex items-start gap-1.5 bg-error/5 px-3 py-1.5 text-[11px] text-error">
|
|
216
234
|
<AlertTriangle size={11} className="mt-0.5 shrink-0" />
|
|
217
235
|
<span className="min-w-0 flex-1 break-words">{notice || modes?.error}</span>
|
|
218
236
|
<button
|
|
@@ -97,4 +97,11 @@ export interface ChatInputProps {
|
|
|
97
97
|
* form lives inside a panel that is already sized to the answers beside it.
|
|
98
98
|
*/
|
|
99
99
|
expandable?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* The reader has scrolled back through the transcript. On a phone with an
|
|
102
|
+
* empty draft the composer then gives its height back to the conversation
|
|
103
|
+
* (`composerExpansion`'s `compact`); it returns to full size as soon as the
|
|
104
|
+
* reader reaches the tail, types, or focuses the box.
|
|
105
|
+
*/
|
|
106
|
+
shrinkWhileReading?: boolean;
|
|
100
107
|
}
|
|
@@ -64,6 +64,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
64
64
|
scrollRef,
|
|
65
65
|
contentRef,
|
|
66
66
|
showScrollBottom,
|
|
67
|
+
readingBack,
|
|
67
68
|
handleScroll,
|
|
68
69
|
jumpToBottom,
|
|
69
70
|
handleSend,
|
|
@@ -93,7 +94,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
93
94
|
// fetch lands) has no card to be matched against yet, so deciding "no card
|
|
94
95
|
// owns this" during that window painted the ask modal and then took it back —
|
|
95
96
|
// the flash on switching to a session parked on a question.
|
|
96
|
-
const { framesByTool, modalRequest } = useMemo(
|
|
97
|
+
const { framesByTool, orphanAsks, modalRequest } = useMemo(
|
|
97
98
|
() => splitAskFrames(localMessages, extensionDialogs, !sessionLoading),
|
|
98
99
|
[localMessages, extensionDialogs, sessionLoading],
|
|
99
100
|
);
|
|
@@ -102,8 +103,8 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
102
103
|
resolveExtensionDialog(request.id);
|
|
103
104
|
}, [respondToExtensionUi, resolveExtensionDialog]);
|
|
104
105
|
const askFrames = useMemo<AskFramesHandle>(
|
|
105
|
-
() => ({ framesByTool, respond: respondToFrame }),
|
|
106
|
-
[framesByTool, respondToFrame],
|
|
106
|
+
() => ({ framesByTool, orphanAsks, respond: respondToFrame }),
|
|
107
|
+
[framesByTool, orphanAsks, respondToFrame],
|
|
107
108
|
);
|
|
108
109
|
|
|
109
110
|
const [newChatInitialContent, setNewChatInitialContent] = useState<string | null>(null);
|
|
@@ -300,6 +301,11 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
300
301
|
sessionModel={typeof sessionData?.model === 'object' ? sessionData.model : null}
|
|
301
302
|
sessionThinkingLevel={sessionData?.thinkingLevel}
|
|
302
303
|
variant={variant}
|
|
304
|
+
// The same scroll geometry that draws the scroll-to-bottom
|
|
305
|
+
// button, with hysteresis: the reader is back through the
|
|
306
|
+
// transcript, so a phone composer may give its height back.
|
|
307
|
+
// `composerExpansion` ignores it on a desktop.
|
|
308
|
+
shrinkWhileReading={readingBack}
|
|
303
309
|
/>
|
|
304
310
|
)}
|
|
305
311
|
</>
|
package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The reported defect: an `ask` dialog that no tool card owns was handed to the
|
|
8
|
+
* MODAL, which has no `ask` body — the panel came up empty with a single
|
|
9
|
+
* "Submit Response" button, and that button sent a bare `{ value }` omp rejects
|
|
10
|
+
* ("Ask dialog response must carry N answers"), so the agent stayed parked on a
|
|
11
|
+
* question the user could not even read.
|
|
12
|
+
*
|
|
13
|
+
* These pin the routing end to end: an ask frame is never `modalRequest`, and
|
|
14
|
+
* with no card to claim it the timeline draws it inline instead.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import { Window } from 'happy-dom';
|
|
19
|
+
import { h, render } from 'preact';
|
|
20
|
+
import { act } from 'preact/test-utils';
|
|
21
|
+
import { AskFramesContext, splitAskFrames, type AskFramesHandle } from '@/client/hooks/chat/timeline/ask-frames';
|
|
22
|
+
import { OrphanAsks } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks';
|
|
23
|
+
import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
|
|
24
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
25
|
+
|
|
26
|
+
const QUESTIONS = [
|
|
27
|
+
{ id: 'q1', question: 'Pick colors', options: [{ label: 'red' }, { label: 'green' }], multi: true },
|
|
28
|
+
{ id: 'q2', question: 'Pick a size', options: [{ label: 's' }, { label: 'm' }] },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const ORPHAN: ExtensionUiDialogRequest = {
|
|
32
|
+
type: 'extension_ui_request',
|
|
33
|
+
id: 'ui-orphan',
|
|
34
|
+
method: 'ask',
|
|
35
|
+
title: '',
|
|
36
|
+
questions: QUESTIONS,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let container: HTMLElement;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
installDomGlobals(new Window());
|
|
43
|
+
container = document.createElement('div');
|
|
44
|
+
document.body.appendChild(container);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
render(null, container);
|
|
49
|
+
container.remove();
|
|
50
|
+
restoreDomGlobals();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(() => restoreDomGlobals());
|
|
54
|
+
|
|
55
|
+
/** The split the timeline computes, with nothing on the timeline to claim it. */
|
|
56
|
+
function splitWithoutCards(frames: ExtensionUiDialogRequest[]) {
|
|
57
|
+
return splitAskFrames([], frames, true);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('an ask dialog with no tool card', () => {
|
|
61
|
+
test('is never handed to the modal', () => {
|
|
62
|
+
const split = splitWithoutCards([ORPHAN]);
|
|
63
|
+
expect(split.modalRequest).toBeNull();
|
|
64
|
+
expect(split.orphanAsks).toEqual([ORPHAN]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('renders inline with every question and its own submit', () => {
|
|
68
|
+
const split = splitWithoutCards([ORPHAN]);
|
|
69
|
+
const handle: AskFramesHandle = { framesByTool: split.framesByTool, orphanAsks: split.orphanAsks, respond: () => {} };
|
|
70
|
+
|
|
71
|
+
act(() => {
|
|
72
|
+
render(h(AskFramesContext.Provider, { value: handle }, h(OrphanAsks, {})), container);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// The question text — the thing the empty modal did not have at all.
|
|
76
|
+
expect(container.textContent).toContain('Pick colors');
|
|
77
|
+
expect(container.textContent).toContain('Pick a size');
|
|
78
|
+
// Its answers are chosen here, and submitted by the card itself.
|
|
79
|
+
expect(container.textContent).toContain('Submit answers');
|
|
80
|
+
// No dialog role anywhere: this is a timeline row, not an overlay.
|
|
81
|
+
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('answering sends one answers entry per question', () => {
|
|
85
|
+
const sent: unknown[] = [];
|
|
86
|
+
const split = splitWithoutCards([ORPHAN]);
|
|
87
|
+
const handle: AskFramesHandle = {
|
|
88
|
+
framesByTool: split.framesByTool,
|
|
89
|
+
orphanAsks: split.orphanAsks,
|
|
90
|
+
respond: (_request, response) => sent.push(response),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
act(() => {
|
|
94
|
+
render(h(AskFramesContext.Provider, { value: handle }, h(OrphanAsks, {})), container);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const button = (text: string) => [...container.querySelectorAll('button')]
|
|
98
|
+
.find((element) => element.textContent?.includes(text)) as HTMLElement | undefined;
|
|
99
|
+
|
|
100
|
+
act(() => { button('red')?.click(); });
|
|
101
|
+
act(() => { button('s')?.click(); });
|
|
102
|
+
act(() => { button('Submit answers')?.click(); });
|
|
103
|
+
|
|
104
|
+
expect(sent).toHaveLength(1);
|
|
105
|
+
expect(sent[0]).toEqual({
|
|
106
|
+
answers: [
|
|
107
|
+
{ id: 'q1', selectedOptions: ['red'] },
|
|
108
|
+
{ id: 'q2', selectedOptions: ['s'] },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('the orphan row is held until the session history has loaded', () => {
|
|
114
|
+
// Same window as the modal hold: on session open a replayed frame looks
|
|
115
|
+
// cardless until the JSONL lands, so drawing it now would paint the
|
|
116
|
+
// question at the tail and then move it onto its card.
|
|
117
|
+
expect(splitAskFrames([], [ORPHAN], false).orphanAsks).toEqual([]);
|
|
118
|
+
expect(splitAskFrames([], [ORPHAN], true).orphanAsks).toEqual([ORPHAN]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('an approval gate still keeps its modal', () => {
|
|
122
|
+
const gate: ExtensionUiDialogRequest = { type: 'extension_ui_request', id: 'g1', method: 'confirm', title: 'Run bash?' };
|
|
123
|
+
const split = splitWithoutCards([gate]);
|
|
124
|
+
expect(split.modalRequest).toBe(gate);
|
|
125
|
+
expect(split.orphanAsks).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ask dialogs with no tool card behind them, drawn IN the timeline.
|
|
8
|
+
*
|
|
9
|
+
* An `ask` frame is normally claimed by the tool call that raised it (see
|
|
10
|
+
* `ask-frames.ts`), but three real shapes have no card to claim:
|
|
11
|
+
*
|
|
12
|
+
* - a subagent's own question — the subagent runs in its own transcript and
|
|
13
|
+
* its tool call is not in this session's row list;
|
|
14
|
+
* - an extension's `askDialog` (the ratchet approval is one), which is not an
|
|
15
|
+
* `ask` tool call at all;
|
|
16
|
+
* - a tool call outside the loaded history window, or one whose arguments
|
|
17
|
+
* never arrived.
|
|
18
|
+
*
|
|
19
|
+
* All of them still BLOCK the agent until answered, so they must stay
|
|
20
|
+
* answerable — and the one place they must not be is a modal over the
|
|
21
|
+
* conversation: `ExtensionDialog` has no `ask` body, so an orphaned frame there
|
|
22
|
+
* rendered an empty panel with a single button, and that button sent a bare
|
|
23
|
+
* `{ value }` that omp rejects ("Ask dialog response must carry N answers").
|
|
24
|
+
* The reported symptom was exactly that empty modal.
|
|
25
|
+
*
|
|
26
|
+
* The card is the same `GroupedAsk` the inline tool card uses, so a question
|
|
27
|
+
* reads identically wherever it came from.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { HelpCircle } from 'lucide-preact';
|
|
31
|
+
import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
|
|
32
|
+
import { useAskFrames } from '@/client/hooks/chat/timeline/ask-frames';
|
|
33
|
+
import { GroupedAsk } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-panel/GroupedAsk';
|
|
34
|
+
import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
|
|
35
|
+
|
|
36
|
+
/** One orphan ask, rendered as a self-contained card at the end of the timeline. */
|
|
37
|
+
function OrphanAskCard({ frame, onRespond }: {
|
|
38
|
+
frame: ExtensionUiDialogRequest;
|
|
39
|
+
onRespond: (request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => void;
|
|
40
|
+
}) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="rounded-lg border border-ink/12 bg-paper p-3 shadow-xs">
|
|
43
|
+
<div className="mb-2 flex items-center gap-1.5 text-[10.5px] text-ink/50">
|
|
44
|
+
<HelpCircle size={11} className="shrink-0" />
|
|
45
|
+
<span>Input needed — the agent is blocked until you answer.</span>
|
|
46
|
+
</div>
|
|
47
|
+
<GroupedAsk
|
|
48
|
+
frame={frame}
|
|
49
|
+
recorded={(frame.questions ?? []).map(() => null)}
|
|
50
|
+
onRespond={(response) => onRespond(frame, response)}
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every orphan ask, oldest first. Rendered inside the timeline's scroll body so
|
|
58
|
+
* it behaves like any other row — a question is part of the conversation, and a
|
|
59
|
+
* surface floating above the transcript hid the turn it belongs to.
|
|
60
|
+
*/
|
|
61
|
+
export function OrphanAsks() {
|
|
62
|
+
const { orphanAsks, respond } = useAskFrames();
|
|
63
|
+
if (orphanAsks.length === 0) return null;
|
|
64
|
+
return (
|
|
65
|
+
<div className="mt-2 space-y-2">
|
|
66
|
+
{orphanAsks.map((frame) => (
|
|
67
|
+
<OrphanAskCard key={frame.id} frame={frame} onRespond={respond} />
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -111,6 +111,7 @@ afterAll(() => restoreDomGlobals());
|
|
|
111
111
|
function mount(tool: ToolCallData, groups: ExtensionUiDialogRequest[][]): void {
|
|
112
112
|
const handle: AskFramesHandle = {
|
|
113
113
|
framesByTool: new Map([[tool.id, groups]]),
|
|
114
|
+
orphanAsks: [],
|
|
114
115
|
respond: (request, response) => calls.push({ request, response }),
|
|
115
116
|
};
|
|
116
117
|
act(() => {
|
package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts
CHANGED
|
@@ -44,7 +44,7 @@ function mountFromFrame(frame: IncomingExtensionUiRequest): ExtensionUiDialogReq
|
|
|
44
44
|
createOmpAgentCallbacks(deps).onExtensionUiRequest?.(frame);
|
|
45
45
|
|
|
46
46
|
const split = splitAskFrames([MESSAGE], queue, true);
|
|
47
|
-
const handle: AskFramesHandle = { framesByTool: split.framesByTool, respond: () => {} };
|
|
47
|
+
const handle: AskFramesHandle = { framesByTool: split.framesByTool, orphanAsks: [], respond: () => {} };
|
|
48
48
|
act(() => { render(h(AskFramesContext.Provider, { value: handle }, h(AskPanel, { tool: ASK_TOOL })), container); });
|
|
49
49
|
return queue;
|
|
50
50
|
}
|
|
@@ -52,7 +52,7 @@ function mountFromFrame(frame: IncomingExtensionUiRequest): ExtensionUiDialogReq
|
|
|
52
52
|
/** Mount the card with an explicit frame queue and tool, keeping it mounted. */
|
|
53
53
|
function paint(queue: ExtensionUiDialogRequest[], tool: ToolCallData = ASK_TOOL): void {
|
|
54
54
|
const split = splitAskFrames([MESSAGE], queue, true);
|
|
55
|
-
const handle: AskFramesHandle = { framesByTool: split.framesByTool, respond: () => {} };
|
|
55
|
+
const handle: AskFramesHandle = { framesByTool: split.framesByTool, orphanAsks: [], respond: () => {} };
|
|
56
56
|
act(() => { render(h(AskFramesContext.Provider, { value: handle }, h(AskPanel, { tool })), container); });
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -170,6 +170,9 @@ export function GitBranchToolbar({
|
|
|
170
170
|
<CloudDownload size={13} />
|
|
171
171
|
</button>
|
|
172
172
|
|
|
173
|
+
{/* The row's only text is the count, so an accessible name built from
|
|
174
|
+
it would read "↑1 ↓1" — it names the ACTION, the same way Fetch
|
|
175
|
+
and Commit History do. */}
|
|
173
176
|
<button
|
|
174
177
|
type="button"
|
|
175
178
|
onClick={onSync}
|
|
@@ -183,6 +186,7 @@ export function GitBranchToolbar({
|
|
|
183
186
|
? `Pull ${syncCount.behind} commit(s)`
|
|
184
187
|
: 'Up to date — sync anyway'
|
|
185
188
|
}
|
|
189
|
+
aria-label="Sync with upstream"
|
|
186
190
|
className="flex items-center gap-1 p-1 rounded hover:text-ink hover:bg-ink/5 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
187
191
|
>
|
|
188
192
|
<RefreshCw size={13} />
|
|
@@ -201,6 +201,21 @@ describe('GitPanel sync badge', () => {
|
|
|
201
201
|
for (let i = 0; i < 20; i += 1) await act(async () => {});
|
|
202
202
|
expect(container.textContent).toContain('↑0 ↓3');
|
|
203
203
|
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The Sync button is enabled whenever no action is running — including the
|
|
207
|
+
* DIVERGED case that used to be refused by git itself. Its accessible name
|
|
208
|
+
* is the action, not the count: a name built from the row's own text reads
|
|
209
|
+
* "↑1 ↓1", which names no control.
|
|
210
|
+
*/
|
|
211
|
+
test('a diverged branch leaves Sync enabled, and the control is named', async () => {
|
|
212
|
+
await mount({ topicSync: { ahead: 1, behind: 1 } });
|
|
213
|
+
|
|
214
|
+
const sync = container.querySelector('button[aria-label="Sync with upstream"]');
|
|
215
|
+
if (!sync) throw new Error('no Sync control rendered');
|
|
216
|
+
expect((sync as HTMLButtonElement).disabled).toBe(false);
|
|
217
|
+
expect(container.textContent).toContain('↑1 ↓1');
|
|
218
|
+
});
|
|
204
219
|
});
|
|
205
220
|
|
|
206
221
|
/**
|
|
@@ -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}
|