@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
|
@@ -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,
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Harness for the timeline-scroll specs, shared by `scroll.test.ts` and
|
|
8
|
+
* `scroll-reading-back.test.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Runs against a real DOM (happy-dom) because these are Preact hooks; geometry
|
|
11
|
+
* is faked on the container, since happy-dom does no layout. Preact binds its
|
|
12
|
+
* environment at module evaluation time, so every Preact/hook module is loaded
|
|
13
|
+
* DYNAMICALLY after the DOM globals exist — a static import would evaluate
|
|
14
|
+
* against the real (or missing) DOM and the hooks would observe nothing.
|
|
15
|
+
*
|
|
16
|
+
* The harness is a factory rather than module-level setup because two spec
|
|
17
|
+
* files share one `bun test` process: each installs its own globals in
|
|
18
|
+
* `beforeAll` and removes them in `afterAll`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Window } from 'happy-dom';
|
|
22
|
+
import type { h as PreactH, render as PreactRender } from 'preact';
|
|
23
|
+
import type { act as PreactAct } from 'preact/test-utils';
|
|
24
|
+
import type {
|
|
25
|
+
useChatTimelineScroll as UseScroll,
|
|
26
|
+
ChatTimelineScrollResult,
|
|
27
|
+
} from '@/client/hooks/chat/timeline/scroll';
|
|
28
|
+
import type { useTimelineAutoScroll as UseAuto } from '@/client/hooks/chat/timeline/auto-scroll';
|
|
29
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
30
|
+
|
|
31
|
+
export interface ScrollGeometry {
|
|
32
|
+
top: number;
|
|
33
|
+
height: number;
|
|
34
|
+
viewport: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ScrollRecord {
|
|
38
|
+
top: number;
|
|
39
|
+
behavior?: ScrollBehavior;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProbeHandle {
|
|
43
|
+
holder: { current: ChatTimelineScrollResult | null };
|
|
44
|
+
node: HTMLDivElement;
|
|
45
|
+
el: HTMLDivElement;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ProbeProps {
|
|
49
|
+
sessionId: string;
|
|
50
|
+
messages: ChatMessageData[];
|
|
51
|
+
/** Holder owned by ONE test: a stale tree can only write its own hook result. */
|
|
52
|
+
holder: { current: ChatTimelineScrollResult | null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** ResizeObserver the tests drive by hand — the hook rebuilds one per content
|
|
56
|
+
* node, so the last instance of the current mount is the live one. */
|
|
57
|
+
export class StubResizeObserver {
|
|
58
|
+
static instances: StubResizeObserver[] = [];
|
|
59
|
+
static latest(): StubResizeObserver {
|
|
60
|
+
return StubResizeObserver.instances[StubResizeObserver.instances.length - 1];
|
|
61
|
+
}
|
|
62
|
+
constructor(private readonly callback: () => void) {
|
|
63
|
+
StubResizeObserver.instances.push(this);
|
|
64
|
+
}
|
|
65
|
+
observe() {}
|
|
66
|
+
unobserve() {}
|
|
67
|
+
disconnect() {}
|
|
68
|
+
fire() {
|
|
69
|
+
this.callback();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ScrollProbe {
|
|
74
|
+
/** Live geometry the fake scroller reports. */
|
|
75
|
+
geometry: ScrollGeometry;
|
|
76
|
+
/** Every scroll the hook asked for, in order. */
|
|
77
|
+
scrolled: ScrollRecord[];
|
|
78
|
+
StubResizeObserver: typeof StubResizeObserver;
|
|
79
|
+
/** Preact's test-utils `act`, loaded after the DOM globals exist. */
|
|
80
|
+
act: typeof PreactAct;
|
|
81
|
+
/** Drain the deferred frames the hook scheduled. */
|
|
82
|
+
flushFrames(): Promise<void>;
|
|
83
|
+
message(id: string): ChatMessageData;
|
|
84
|
+
mountProbe(sessionId: string, messages: ChatMessageData[]): Promise<ProbeHandle>;
|
|
85
|
+
updateProbe(probe: ProbeHandle, sessionId: string, messages: ChatMessageData[]): Promise<void>;
|
|
86
|
+
unmountProbe(probe: ProbeHandle): Promise<void>;
|
|
87
|
+
/** Remove the DOM globals this harness installed. */
|
|
88
|
+
teardown(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function setupScrollProbe(): Promise<ScrollProbe> {
|
|
92
|
+
const win = new Window({ url: 'http://localhost' });
|
|
93
|
+
const keys = [
|
|
94
|
+
'window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event',
|
|
95
|
+
] as const;
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
(globalThis as Record<string, unknown>)[key] = (win as unknown as Record<string, unknown>)[key];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
StubResizeObserver.instances = [];
|
|
101
|
+
(globalThis as Record<string, unknown>).ResizeObserver = StubResizeObserver;
|
|
102
|
+
|
|
103
|
+
const geometry: ScrollGeometry = { top: 0, height: 5000, viewport: 500 };
|
|
104
|
+
const scrolled: ScrollRecord[] = [];
|
|
105
|
+
/** Frame callbacks the hook's observer deferred; index 0 is handle 1. */
|
|
106
|
+
const pendingFrames: Array<(() => void) | null> = [];
|
|
107
|
+
|
|
108
|
+
// The hook defers its resize-driven pin by one frame, and happy-dom's own rAF
|
|
109
|
+
// never runs without a real event loop, so the queue is drained by hand:
|
|
110
|
+
// `flushFrames()` runs whatever a delivery scheduled.
|
|
111
|
+
(globalThis as Record<string, unknown>).requestAnimationFrame = (cb: () => void) => pendingFrames.push(cb);
|
|
112
|
+
(globalThis as Record<string, unknown>).cancelAnimationFrame = (handle: number) => {
|
|
113
|
+
pendingFrames[handle - 1] = null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Observe what the hook asks for; 'instant' also lands (a smooth scroll would
|
|
117
|
+
* animate in a browser, so the fake keeps it as a recorded intent only). */
|
|
118
|
+
function applyTop(el: HTMLDivElement, value: number) {
|
|
119
|
+
geometry.top = Math.max(0, Math.min(value, geometry.height - geometry.viewport));
|
|
120
|
+
// The global `Event` the hook's listener expects: happy-dom's, installed
|
|
121
|
+
// into globalThis above.
|
|
122
|
+
el.dispatchEvent(new Event('scroll'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function installScroller(el: HTMLDivElement) {
|
|
126
|
+
Object.defineProperty(el, 'scrollHeight', { get: () => geometry.height, configurable: true });
|
|
127
|
+
Object.defineProperty(el, 'clientHeight', { get: () => geometry.viewport, configurable: true });
|
|
128
|
+
Object.defineProperty(el, 'scrollTop', {
|
|
129
|
+
get: () => geometry.top,
|
|
130
|
+
set: (value: number) => applyTop(el, value),
|
|
131
|
+
configurable: true,
|
|
132
|
+
});
|
|
133
|
+
Object.defineProperty(el, 'scrollTo', {
|
|
134
|
+
value: (options: ScrollRecord) => {
|
|
135
|
+
scrolled.push(options);
|
|
136
|
+
if (options.behavior !== 'smooth') applyTop(el, options.top);
|
|
137
|
+
},
|
|
138
|
+
configurable: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Static imports cannot work here: the modules below must load after the DOM
|
|
143
|
+
// globals are installed (see the module header).
|
|
144
|
+
const { useChatTimelineScroll }: { useChatTimelineScroll: typeof UseScroll } =
|
|
145
|
+
await import('@/client/hooks/chat/timeline/scroll');
|
|
146
|
+
const { useTimelineAutoScroll }: { useTimelineAutoScroll: typeof UseAuto } =
|
|
147
|
+
await import('@/client/hooks/chat/timeline/auto-scroll');
|
|
148
|
+
const { h, render }: { h: typeof PreactH; render: typeof PreactRender } = await import('preact');
|
|
149
|
+
const { act }: { act: typeof PreactAct } = await import('preact/test-utils');
|
|
150
|
+
|
|
151
|
+
function Probe(props: ProbeProps) {
|
|
152
|
+
const scroll = useChatTimelineScroll({});
|
|
153
|
+
useTimelineAutoScroll({
|
|
154
|
+
sessionId: props.sessionId,
|
|
155
|
+
messages: props.messages,
|
|
156
|
+
scrollRef: scroll.scrollRef,
|
|
157
|
+
jumpToBottom: scroll.jumpToBottom,
|
|
158
|
+
enabled: true,
|
|
159
|
+
});
|
|
160
|
+
props.holder.current = scroll;
|
|
161
|
+
return h('div', { ref: scroll.scrollRef, onScroll: scroll.handleScroll },
|
|
162
|
+
h('div', { ref: scroll.contentRef }));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const message = (id: string) => ({ id, role: 'user', content: id } as unknown as ChatMessageData);
|
|
166
|
+
|
|
167
|
+
/** Fresh mount, own container: `follow`, the jump counter and the observers
|
|
168
|
+
* are per-hook state, so sharing them across tests would hide the behaviour. */
|
|
169
|
+
async function mountProbe(sessionId: string, messages: ChatMessageData[]): Promise<ProbeHandle> {
|
|
170
|
+
const holder: ProbeHandle['holder'] = { current: null };
|
|
171
|
+
const node = document.createElement('div');
|
|
172
|
+
document.body.appendChild(node);
|
|
173
|
+
await act(async () => { render(h(Probe, { sessionId, messages, holder }), node); });
|
|
174
|
+
const el = holder.current!.scrollRef.current!;
|
|
175
|
+
installScroller(el);
|
|
176
|
+
return { holder, node, el };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Re-render with new props: a session switch, then that session's history. */
|
|
180
|
+
async function updateProbe(probe: ProbeHandle, sessionId: string, messages: ChatMessageData[]) {
|
|
181
|
+
await act(async () => {
|
|
182
|
+
render(h(Probe, { sessionId, messages, holder: probe.holder }), probe.node);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function unmountProbe(probe: ProbeHandle) {
|
|
187
|
+
await act(async () => { render(null, probe.node); });
|
|
188
|
+
probe.node.remove();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Drain the deferred frames. The resize-driven pin is deliberately NOT applied
|
|
193
|
+
* inside the ResizeObserver delivery — a layout write there makes the browser
|
|
194
|
+
* re-run its observers in the same frame, and past its depth limit it reports
|
|
195
|
+
* "ResizeObserver loop completed with undelivered notifications." — so a test
|
|
196
|
+
* that fires an observer has to let the frame it scheduled run before the
|
|
197
|
+
* geometry it asserts on has been applied.
|
|
198
|
+
*/
|
|
199
|
+
async function flushFrames() {
|
|
200
|
+
for (let i = 0; i < pendingFrames.length; i++) {
|
|
201
|
+
const callback = pendingFrames[i];
|
|
202
|
+
pendingFrames[i] = null;
|
|
203
|
+
if (!callback) continue;
|
|
204
|
+
await act(async () => { callback(); });
|
|
205
|
+
}
|
|
206
|
+
pendingFrames.length = 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function teardown() {
|
|
210
|
+
for (const key of [...keys, 'ResizeObserver', 'requestAnimationFrame', 'cancelAnimationFrame']) {
|
|
211
|
+
delete (globalThis as Record<string, unknown>)[key];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
geometry,
|
|
217
|
+
scrolled,
|
|
218
|
+
StubResizeObserver,
|
|
219
|
+
act,
|
|
220
|
+
flushFrames,
|
|
221
|
+
message,
|
|
222
|
+
mountProbe,
|
|
223
|
+
updateProbe,
|
|
224
|
+
unmountProbe,
|
|
225
|
+
teardown,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The timeline's reading-back hysteresis, which is what a phone composer
|
|
8
|
+
* shrinks on.
|
|
9
|
+
*
|
|
10
|
+
* It is hysteretic for a reason the geometry makes unavoidable: compacting the
|
|
11
|
+
* composer GROWS the scroller's viewport by the height it gave back, which
|
|
12
|
+
* moves the reader CLOSER to the tail. A single threshold would then release
|
|
13
|
+
* the flag, restore the box, and push the reader back out — a render loop.
|
|
14
|
+
*
|
|
15
|
+
* The follow gate and the deferred re-pin live in `scroll.test.ts`; both files
|
|
16
|
+
* share `scroll-probe.test-util.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
20
|
+
import type { ScrollProbe } from '@/client/hooks/chat/timeline/scroll-probe.test-util';
|
|
21
|
+
|
|
22
|
+
let p: ScrollProbe;
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
// Dynamic: the harness installs the DOM globals BEFORE Preact and the hooks
|
|
26
|
+
// evaluate, which a static import would pre-empt (see the harness header).
|
|
27
|
+
const { setupScrollProbe } = await import('@/client/hooks/chat/timeline/scroll-probe.test-util');
|
|
28
|
+
p = await setupScrollProbe();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
p.teardown();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('timeline reading back', () => {
|
|
36
|
+
test('readingBack engages further back than the jump button and releases at the tail', async () => {
|
|
37
|
+
p.scrolled.length = 0;
|
|
38
|
+
p.geometry.top = 0;
|
|
39
|
+
p.geometry.height = 5000;
|
|
40
|
+
const probe = await p.mountProbe('a', [p.message('a1')]);
|
|
41
|
+
const scroll = () => probe.holder.current!;
|
|
42
|
+
/** Both flags are state, so the commit that carries them is a render. */
|
|
43
|
+
const readAt = async (top: number) => {
|
|
44
|
+
await p.act(() => { probe.el.scrollTop = top; });
|
|
45
|
+
return { jump: scroll().showScrollBottom, compact: scroll().readingBack };
|
|
46
|
+
};
|
|
47
|
+
const tail = p.geometry.height - p.geometry.viewport;
|
|
48
|
+
|
|
49
|
+
// At the tail: neither flag.
|
|
50
|
+
expect(await readAt(tail)).toEqual({ jump: false, compact: false });
|
|
51
|
+
|
|
52
|
+
// Past the jump-button threshold but short of the compaction one: the
|
|
53
|
+
// button is offered, the composer is NOT shrunk.
|
|
54
|
+
expect(await readAt(tail - 150)).toEqual({ jump: true, compact: false });
|
|
55
|
+
|
|
56
|
+
// Further back: both.
|
|
57
|
+
expect(await readAt(tail - 400)).toEqual({ jump: true, compact: true });
|
|
58
|
+
|
|
59
|
+
// Coming back: the composer stays shrunk through the jump-button window,
|
|
60
|
+
// so the 24px a compaction gives back cannot release it.
|
|
61
|
+
expect(await readAt(tail - 150)).toEqual({ jump: true, compact: true });
|
|
62
|
+
|
|
63
|
+
// The tail releases it.
|
|
64
|
+
expect(await readAt(tail)).toEqual({ jump: false, compact: false });
|
|
65
|
+
await p.unmountProbe(probe);
|
|
66
|
+
});
|
|
67
|
+
});
|