@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
|
@@ -17,179 +17,37 @@
|
|
|
17
17
|
* pinned (a prepended window, a late Shiki/mermaid/image layout), so a jump
|
|
18
18
|
* target measured a frame earlier — or at open time — was stranded.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* The reading-back hysteresis is its own file (`scroll-reading-back.test.ts`),
|
|
21
|
+
* because the two halves of that contract — the composer shrinking and the
|
|
22
|
+
* threshold that releases it — are a different feature from the follow gate.
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
25
|
import { afterAll, beforeAll, describe, expect, jest as fakeTimers, test } from 'bun:test';
|
|
25
|
-
import {
|
|
26
|
-
import type { h as PreactH, render as PreactRender } from 'preact';
|
|
27
|
-
import type { act as PreactAct } from 'preact/test-utils';
|
|
28
|
-
import type { useChatTimelineScroll as UseScroll, ChatTimelineScrollResult } from '@/client/hooks/chat/timeline/scroll';
|
|
29
|
-
import type { useTimelineAutoScroll as UseAuto } from '@/client/hooks/chat/timeline/auto-scroll';
|
|
30
|
-
import type { ChatMessageData } from '@/shared/types';
|
|
26
|
+
import type { ScrollProbe } from '@/client/hooks/chat/timeline/scroll-probe.test-util';
|
|
31
27
|
|
|
32
|
-
let
|
|
33
|
-
let useTimelineAutoScroll: typeof UseAuto;
|
|
34
|
-
let h: typeof PreactH;
|
|
35
|
-
let render: typeof PreactRender;
|
|
36
|
-
let act: typeof PreactAct;
|
|
37
|
-
|
|
38
|
-
let win: Window;
|
|
39
|
-
const scrolled: Array<{ top: number; behavior?: ScrollBehavior }> = [];
|
|
40
|
-
const geometry = { top: 0, height: 5000, viewport: 500 };
|
|
41
|
-
|
|
42
|
-
/** Observe what the hook asks for; 'instant' also lands (a smooth scroll would
|
|
43
|
-
* animate in a browser, so the fake keeps it as a recorded intent only). */
|
|
44
|
-
function applyTop(el: HTMLDivElement, value: number) {
|
|
45
|
-
geometry.top = Math.max(0, Math.min(value, geometry.height - geometry.viewport));
|
|
46
|
-
// The global `Event` the hook's listener expects: happy-dom's, installed
|
|
47
|
-
// into globalThis in beforeAll.
|
|
48
|
-
el.dispatchEvent(new Event('scroll'));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function installScroller(el: HTMLDivElement) {
|
|
52
|
-
Object.defineProperty(el, 'scrollHeight', { get: () => geometry.height, configurable: true });
|
|
53
|
-
Object.defineProperty(el, 'clientHeight', { get: () => geometry.viewport, configurable: true });
|
|
54
|
-
Object.defineProperty(el, 'scrollTop', {
|
|
55
|
-
get: () => geometry.top,
|
|
56
|
-
set: (value: number) => applyTop(el, value),
|
|
57
|
-
configurable: true,
|
|
58
|
-
});
|
|
59
|
-
Object.defineProperty(el, 'scrollTo', {
|
|
60
|
-
value: (options: { top: number; behavior?: ScrollBehavior }) => {
|
|
61
|
-
scrolled.push(options);
|
|
62
|
-
if (options.behavior !== 'smooth') applyTop(el, options.top);
|
|
63
|
-
},
|
|
64
|
-
configurable: true,
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** ResizeObserver the tests drive by hand — the hook rebuilds one per content
|
|
69
|
-
* node, so the last instance of the current mount is the live one. */
|
|
70
|
-
class StubResizeObserver {
|
|
71
|
-
static instances: StubResizeObserver[] = [];
|
|
72
|
-
static latest(): StubResizeObserver { return StubResizeObserver.instances[StubResizeObserver.instances.length - 1]; }
|
|
73
|
-
constructor(private readonly callback: () => void) {
|
|
74
|
-
StubResizeObserver.instances.push(this);
|
|
75
|
-
}
|
|
76
|
-
observe() {}
|
|
77
|
-
unobserve() {}
|
|
78
|
-
disconnect() {}
|
|
79
|
-
fire() { this.callback(); }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Frame callbacks the hook's observer deferred; index 0 is handle 1. */
|
|
83
|
-
const pendingFrames: Array<(() => void) | null> = [];
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Drain the deferred frames. The resize-driven pin is deliberately NOT applied
|
|
87
|
-
* inside the ResizeObserver delivery — a layout write there makes the browser
|
|
88
|
-
* re-run its observers in the same frame, and past its depth limit it reports
|
|
89
|
-
* "ResizeObserver loop completed with undelivered notifications." — so a test
|
|
90
|
-
* that fires an observer has to let the frame it scheduled run before the
|
|
91
|
-
* geometry it asserts on has been applied.
|
|
92
|
-
*/
|
|
93
|
-
async function flushFrames() {
|
|
94
|
-
for (let i = 0; i < pendingFrames.length; i++) {
|
|
95
|
-
const callback = pendingFrames[i];
|
|
96
|
-
pendingFrames[i] = null;
|
|
97
|
-
if (!callback) continue;
|
|
98
|
-
await act(async () => { callback(); });
|
|
99
|
-
}
|
|
100
|
-
pendingFrames.length = 0;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
interface ProbeProps {
|
|
104
|
-
sessionId: string;
|
|
105
|
-
messages: ChatMessageData[];
|
|
106
|
-
/** Holder owned by ONE test: a stale tree can only write its own hook result. */
|
|
107
|
-
holder: { current: ChatTimelineScrollResult | null };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function Probe(props: ProbeProps) {
|
|
111
|
-
const scroll = useChatTimelineScroll({});
|
|
112
|
-
useTimelineAutoScroll({
|
|
113
|
-
sessionId: props.sessionId,
|
|
114
|
-
messages: props.messages,
|
|
115
|
-
scrollRef: scroll.scrollRef,
|
|
116
|
-
jumpToBottom: scroll.jumpToBottom,
|
|
117
|
-
enabled: true,
|
|
118
|
-
});
|
|
119
|
-
props.holder.current = scroll;
|
|
120
|
-
return h('div', { ref: scroll.scrollRef, onScroll: scroll.handleScroll },
|
|
121
|
-
h('div', { ref: scroll.contentRef }));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
interface ProbeHandle {
|
|
125
|
-
holder: { current: ChatTimelineScrollResult | null };
|
|
126
|
-
node: HTMLDivElement;
|
|
127
|
-
el: HTMLDivElement;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const message = (id: string) => ({ id, role: 'user', content: id } as unknown as ChatMessageData);
|
|
131
|
-
|
|
132
|
-
/** Fresh mount, own container: `follow`, the jump counter and the observers are
|
|
133
|
-
* per-hook state, so sharing them across tests would hide the behaviour. */
|
|
134
|
-
async function mountProbe(sessionId: string, messages: ChatMessageData[]): Promise<ProbeHandle> {
|
|
135
|
-
const holder: ProbeHandle['holder'] = { current: null };
|
|
136
|
-
const node = document.createElement('div');
|
|
137
|
-
document.body.appendChild(node);
|
|
138
|
-
await act(async () => { render(h(Probe, { sessionId, messages, holder }), node); });
|
|
139
|
-
const el = holder.current!.scrollRef.current!;
|
|
140
|
-
installScroller(el);
|
|
141
|
-
return { holder, node, el };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Re-render with new props: a session switch, then that session's history. */
|
|
145
|
-
async function updateProbe(probe: ProbeHandle, sessionId: string, messages: ChatMessageData[]) {
|
|
146
|
-
await act(async () => {
|
|
147
|
-
render(h(Probe, { sessionId, messages, holder: probe.holder }), probe.node);
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async function unmountProbe(probe: ProbeHandle) {
|
|
152
|
-
await act(async () => { render(null, probe.node); });
|
|
153
|
-
probe.node.remove();
|
|
154
|
-
}
|
|
28
|
+
let p: ScrollProbe;
|
|
155
29
|
|
|
156
30
|
beforeAll(async () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
(globalThis as Record<string, unknown>).ResizeObserver = StubResizeObserver;
|
|
162
|
-
// The hook defers its resize-driven pin by one frame, and happy-dom's own rAF
|
|
163
|
-
// never runs without a real event loop, so the queue is drained by hand:
|
|
164
|
-
// `flushFrames()` runs whatever a delivery scheduled.
|
|
165
|
-
(globalThis as Record<string, unknown>).requestAnimationFrame = (cb: () => void) => pendingFrames.push(cb);
|
|
166
|
-
(globalThis as Record<string, unknown>).cancelAnimationFrame = (handle: number) => {
|
|
167
|
-
pendingFrames[handle - 1] = null;
|
|
168
|
-
};
|
|
169
|
-
// Static imports cannot work here: Preact binds its environment at module
|
|
170
|
-
// evaluation time, so the modules below must load after those DOM globals.
|
|
171
|
-
({ useChatTimelineScroll } = await import('@/client/hooks/chat/timeline/scroll'));
|
|
172
|
-
({ useTimelineAutoScroll } = await import('@/client/hooks/chat/timeline/auto-scroll'));
|
|
173
|
-
({ h, render } = await import('preact'));
|
|
174
|
-
({ act } = await import('preact/test-utils'));
|
|
31
|
+
// Dynamic: the harness installs the DOM globals BEFORE Preact and the hooks
|
|
32
|
+
// evaluate, which a static import would pre-empt (see the harness header).
|
|
33
|
+
const { setupScrollProbe } = await import('@/client/hooks/chat/timeline/scroll-probe.test-util');
|
|
34
|
+
p = await setupScrollProbe();
|
|
175
35
|
});
|
|
176
36
|
|
|
177
37
|
afterAll(() => {
|
|
178
|
-
|
|
179
|
-
delete (globalThis as Record<string, unknown>)[key];
|
|
180
|
-
}
|
|
38
|
+
p.teardown();
|
|
181
39
|
});
|
|
182
40
|
|
|
183
41
|
describe('timeline scroll', () => {
|
|
184
42
|
test('opening a session jumps to the tail even after the user scrolled up', async () => {
|
|
185
|
-
scrolled.length = 0;
|
|
186
|
-
geometry.top = 0;
|
|
187
|
-
geometry.height = 5000;
|
|
188
|
-
const probe = await mountProbe('a', [message('a1')]);
|
|
43
|
+
p.scrolled.length = 0;
|
|
44
|
+
p.geometry.top = 0;
|
|
45
|
+
p.geometry.height = 5000;
|
|
46
|
+
const probe = await p.mountProbe('a', [p.message('a1')]);
|
|
189
47
|
const scroll = () => probe.holder.current!;
|
|
190
48
|
|
|
191
|
-
await updateProbe(probe, 'a', [message('a1'), message('a2')]);
|
|
192
|
-
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
49
|
+
await p.updateProbe(probe, 'a', [p.message('a1'), p.message('a2')]);
|
|
50
|
+
expect(probe.el.scrollTop).toBe(p.geometry.height - p.geometry.viewport);
|
|
193
51
|
|
|
194
52
|
// The user reads back — follow disengages, and that state outlives the
|
|
195
53
|
// session it was set in.
|
|
@@ -198,123 +56,123 @@ describe('timeline scroll', () => {
|
|
|
198
56
|
|
|
199
57
|
// Session switch: the container paints empty first (the skeleton), the
|
|
200
58
|
// committed history lands a commit later.
|
|
201
|
-
await updateProbe(probe, 'b', []);
|
|
202
|
-
scrolled.length = 0;
|
|
203
|
-
await updateProbe(probe, 'b', [message('b1')]);
|
|
59
|
+
await p.updateProbe(probe, 'b', []);
|
|
60
|
+
p.scrolled.length = 0;
|
|
61
|
+
await p.updateProbe(probe, 'b', [p.message('b1')]);
|
|
204
62
|
|
|
205
63
|
expect(scroll().followRef.current).toBe(true);
|
|
206
|
-
expect(scrolled.length).toBe(1);
|
|
207
|
-
expect(scrolled[0].behavior).toBe('instant');
|
|
208
|
-
expect(scrolled[0].top).toBe(geometry.height);
|
|
209
|
-
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
210
|
-
await unmountProbe(probe);
|
|
64
|
+
expect(p.scrolled.length).toBe(1);
|
|
65
|
+
expect(p.scrolled[0].behavior).toBe('instant');
|
|
66
|
+
expect(p.scrolled[0].top).toBe(p.geometry.height);
|
|
67
|
+
expect(probe.el.scrollTop).toBe(p.geometry.height - p.geometry.viewport);
|
|
68
|
+
await p.unmountProbe(probe);
|
|
211
69
|
});
|
|
212
70
|
|
|
213
71
|
test('a stream chunk may not move an unpinned viewport, a jump may', async () => {
|
|
214
|
-
scrolled.length = 0;
|
|
215
|
-
geometry.top = 0;
|
|
216
|
-
geometry.height = 5000;
|
|
217
|
-
const probe = await mountProbe('a', [message('a1')]);
|
|
72
|
+
p.scrolled.length = 0;
|
|
73
|
+
p.geometry.top = 0;
|
|
74
|
+
p.geometry.height = 5000;
|
|
75
|
+
const probe = await p.mountProbe('a', [p.message('a1')]);
|
|
218
76
|
const scroll = () => probe.holder.current!;
|
|
219
77
|
|
|
220
|
-
await act(
|
|
78
|
+
await p.act(() => { scroll().jumpToBottom('instant'); });
|
|
221
79
|
expect(scroll().followRef.current).toBe(true);
|
|
222
|
-
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
80
|
+
expect(probe.el.scrollTop).toBe(p.geometry.height - p.geometry.viewport);
|
|
223
81
|
expect(scroll().jumpCountRef.current).toBe(1);
|
|
224
82
|
expect(scroll().jumpActiveRef.current).toBe(true);
|
|
225
83
|
|
|
226
84
|
// The user reads back. A stream chunk must not yank the viewport down...
|
|
227
85
|
probe.el.scrollTop = 100;
|
|
228
86
|
expect(scroll().followRef.current).toBe(false);
|
|
229
|
-
scrolled.length = 0;
|
|
230
|
-
await act(
|
|
231
|
-
expect(scrolled.length).toBe(0);
|
|
87
|
+
p.scrolled.length = 0;
|
|
88
|
+
await p.act(() => { scroll().scrollToBottom('smooth'); });
|
|
89
|
+
expect(p.scrolled.length).toBe(0);
|
|
232
90
|
|
|
233
91
|
// ...while the explicit jump bypasses the gate and re-engages it.
|
|
234
|
-
await act(
|
|
235
|
-
expect(scrolled.length).toBe(1);
|
|
92
|
+
await p.act(() => { scroll().jumpToBottom('instant'); });
|
|
93
|
+
expect(p.scrolled.length).toBe(1);
|
|
236
94
|
expect(scroll().followRef.current).toBe(true);
|
|
237
|
-
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
95
|
+
expect(probe.el.scrollTop).toBe(p.geometry.height - p.geometry.viewport);
|
|
238
96
|
expect(scroll().jumpCountRef.current).toBe(2);
|
|
239
|
-
await unmountProbe(probe);
|
|
97
|
+
await p.unmountProbe(probe);
|
|
240
98
|
});
|
|
241
99
|
|
|
242
100
|
test('the jump guard releases so history paging is not blocked for good', async () => {
|
|
243
|
-
scrolled.length = 0;
|
|
244
|
-
geometry.top = 0;
|
|
245
|
-
geometry.height = 5000;
|
|
246
|
-
const probe = await mountProbe('a', [message('a1')]);
|
|
101
|
+
p.scrolled.length = 0;
|
|
102
|
+
p.geometry.top = 0;
|
|
103
|
+
p.geometry.height = 5000;
|
|
104
|
+
const probe = await p.mountProbe('a', [p.message('a1')]);
|
|
247
105
|
const scroll = () => probe.holder.current!;
|
|
248
106
|
|
|
249
107
|
fakeTimers.useFakeTimers();
|
|
250
|
-
await act(
|
|
108
|
+
await p.act(() => { scroll().jumpToBottom('instant'); });
|
|
251
109
|
expect(scroll().jumpActiveRef.current).toBe(true);
|
|
252
110
|
|
|
253
|
-
await act(
|
|
111
|
+
await p.act(() => { fakeTimers.advanceTimersByTime(1000); });
|
|
254
112
|
expect(scroll().jumpActiveRef.current).toBe(false);
|
|
255
113
|
fakeTimers.useRealTimers();
|
|
256
|
-
await unmountProbe(probe);
|
|
114
|
+
await p.unmountProbe(probe);
|
|
257
115
|
});
|
|
258
116
|
|
|
259
117
|
test('content growing while pinned re-pins the tail; while reading it does not', async () => {
|
|
260
|
-
scrolled.length = 0;
|
|
261
|
-
geometry.top = 0;
|
|
262
|
-
geometry.height = 5000;
|
|
263
|
-
const probe = await mountProbe('a', [message('a1')]);
|
|
118
|
+
p.scrolled.length = 0;
|
|
119
|
+
p.geometry.top = 0;
|
|
120
|
+
p.geometry.height = 5000;
|
|
121
|
+
const probe = await p.mountProbe('a', [p.message('a1')]);
|
|
264
122
|
const scroll = () => probe.holder.current!;
|
|
265
123
|
|
|
266
|
-
await updateProbe(probe, 'a', [message('a1')]);
|
|
267
|
-
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
124
|
+
await p.updateProbe(probe, 'a', [p.message('a1')]);
|
|
125
|
+
expect(probe.el.scrollTop).toBe(p.geometry.height - p.geometry.viewport);
|
|
268
126
|
|
|
269
127
|
// A prepended window / a late layout: the tail moved down by 800px.
|
|
270
|
-
geometry.height += 800;
|
|
271
|
-
const observer = StubResizeObserver.latest();
|
|
272
|
-
scrolled.length = 0;
|
|
273
|
-
await act(
|
|
128
|
+
p.geometry.height += 800;
|
|
129
|
+
const observer = p.StubResizeObserver.latest();
|
|
130
|
+
p.scrolled.length = 0;
|
|
131
|
+
await p.act(() => { observer.fire(); });
|
|
274
132
|
// The delivery itself writes nothing — that is the loop fix.
|
|
275
|
-
expect(scrolled.length).toBe(0);
|
|
276
|
-
await flushFrames();
|
|
277
|
-
expect(scrolled.length).toBe(1);
|
|
278
|
-
expect(scrolled[0].behavior).toBe('instant');
|
|
279
|
-
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
133
|
+
expect(p.scrolled.length).toBe(0);
|
|
134
|
+
await p.flushFrames();
|
|
135
|
+
expect(p.scrolled.length).toBe(1);
|
|
136
|
+
expect(p.scrolled[0].behavior).toBe('instant');
|
|
137
|
+
expect(probe.el.scrollTop).toBe(p.geometry.height - p.geometry.viewport);
|
|
280
138
|
|
|
281
139
|
// Reading back: the same growth must leave the viewport alone.
|
|
282
140
|
probe.el.scrollTop = 400;
|
|
283
|
-
scrolled.length = 0;
|
|
284
|
-
geometry.height += 800;
|
|
285
|
-
await act(
|
|
286
|
-
await flushFrames();
|
|
287
|
-
expect(scrolled.length).toBe(0);
|
|
141
|
+
p.scrolled.length = 0;
|
|
142
|
+
p.geometry.height += 800;
|
|
143
|
+
await p.act(() => { p.StubResizeObserver.latest().fire(); });
|
|
144
|
+
await p.flushFrames();
|
|
145
|
+
expect(p.scrolled.length).toBe(0);
|
|
288
146
|
expect(probe.el.scrollTop).toBe(400);
|
|
289
147
|
expect(scroll().followRef.current).toBe(false);
|
|
290
|
-
await unmountProbe(probe);
|
|
148
|
+
await p.unmountProbe(probe);
|
|
291
149
|
});
|
|
292
150
|
|
|
293
151
|
test('a delivery storm collapses to one pin, and none of them writes inside the delivery', async () => {
|
|
294
|
-
scrolled.length = 0;
|
|
295
|
-
geometry.top = 0;
|
|
296
|
-
geometry.height = 5000;
|
|
297
|
-
const probe = await mountProbe('a', [message('a1')]);
|
|
152
|
+
p.scrolled.length = 0;
|
|
153
|
+
p.geometry.top = 0;
|
|
154
|
+
p.geometry.height = 5000;
|
|
155
|
+
const probe = await p.mountProbe('a', [p.message('a1')]);
|
|
298
156
|
|
|
299
|
-
await updateProbe(probe, 'a', [message('a1')]);
|
|
300
|
-
scrolled.length = 0;
|
|
157
|
+
await p.updateProbe(probe, 'a', [p.message('a1')]);
|
|
158
|
+
p.scrolled.length = 0;
|
|
301
159
|
|
|
302
160
|
// A panel-width transition delivers a new box every frame. Each delivery
|
|
303
161
|
// used to write `scrollTop` synchronously, which is what made the browser
|
|
304
162
|
// re-run its observers within the frame and eventually report a
|
|
305
163
|
// ResizeObserver loop; the deferred pin coalesces the whole burst into one.
|
|
306
|
-
const observer = StubResizeObserver.latest();
|
|
307
|
-
geometry.height += 800;
|
|
308
|
-
await act(
|
|
309
|
-
geometry.height += 800;
|
|
310
|
-
await act(
|
|
311
|
-
geometry.height += 800;
|
|
312
|
-
await act(
|
|
313
|
-
expect(scrolled.length).toBe(0);
|
|
314
|
-
|
|
315
|
-
await flushFrames();
|
|
316
|
-
expect(scrolled.length).toBe(1);
|
|
317
|
-
expect(probe.el.scrollTop).toBe(geometry.height - geometry.viewport);
|
|
318
|
-
await unmountProbe(probe);
|
|
164
|
+
const observer = p.StubResizeObserver.latest();
|
|
165
|
+
p.geometry.height += 800;
|
|
166
|
+
await p.act(() => { observer.fire(); });
|
|
167
|
+
p.geometry.height += 800;
|
|
168
|
+
await p.act(() => { observer.fire(); });
|
|
169
|
+
p.geometry.height += 800;
|
|
170
|
+
await p.act(() => { observer.fire(); });
|
|
171
|
+
expect(p.scrolled.length).toBe(0);
|
|
172
|
+
|
|
173
|
+
await p.flushFrames();
|
|
174
|
+
expect(p.scrolled.length).toBe(1);
|
|
175
|
+
expect(probe.el.scrollTop).toBe(p.geometry.height - p.geometry.viewport);
|
|
176
|
+
await p.unmountProbe(probe);
|
|
319
177
|
});
|
|
320
178
|
});
|
|
@@ -33,6 +33,20 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
|
33
33
|
/** Distance from the tail, in pixels, that still counts as "at the bottom". */
|
|
34
34
|
const FOLLOW_BOTTOM_THRESHOLD_PX = 100;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* How much further back the reader must be for a phone composer to give its
|
|
38
|
+
* height back, on top of {@link FOLLOW_BOTTOM_THRESHOLD_PX}.
|
|
39
|
+
*
|
|
40
|
+
* This is hysteresis, and it is load-bearing: shrinking the composer grows the
|
|
41
|
+
* scroller's viewport by the height it gave back (68 → 44px, i.e. 24px), which
|
|
42
|
+
* by itself moves the reader 24px CLOSER to the tail. Without the margin a
|
|
43
|
+
* reader sitting just past the threshold would shrink the box, land inside the
|
|
44
|
+
* threshold, restore it, and land outside it again — a loop that re-renders the
|
|
45
|
+
* composer forever. The margin exceeds the give-back, so a compaction can never
|
|
46
|
+
* cross back over the restore threshold on its own.
|
|
47
|
+
*/
|
|
48
|
+
const COMPACT_TRIGGER_PX = FOLLOW_BOTTOM_THRESHOLD_PX + 64;
|
|
49
|
+
|
|
36
50
|
/** How long a bottom jump counts as settling. The scroll handler reads "the
|
|
37
51
|
* viewport is at the top, fetch older history" from geometry, and a jump that
|
|
38
52
|
* STARTS at the top (the button, on a session scrolled back to its head)
|
|
@@ -51,6 +65,14 @@ export interface ChatTimelineScrollResult {
|
|
|
51
65
|
/** Attach to the timeline's inner content wrapper so its growth is observed. */
|
|
52
66
|
contentRef: (node: HTMLDivElement | null) => void;
|
|
53
67
|
showScrollBottom: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* The reader is back through the transcript, with hysteresis: it engages
|
|
70
|
+
* further from the tail than {@link showScrollBottom} does and releases at
|
|
71
|
+
* the same distance, so a surface that CHANGES SIZE in response cannot
|
|
72
|
+
* oscillate around the threshold. A phone composer uses it to give its height
|
|
73
|
+
* back to the conversation.
|
|
74
|
+
*/
|
|
75
|
+
readingBack: boolean;
|
|
54
76
|
/** Streaming auto-scroll gates on this: pinned to the tail = true. */
|
|
55
77
|
followRef: RefObject<boolean>;
|
|
56
78
|
/** Bottom jumps issued so far. A paging request stamps its viewport anchor
|
|
@@ -82,6 +104,11 @@ export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}):
|
|
|
82
104
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
83
105
|
const [contentNode, setContentNode] = useState<HTMLDivElement | null>(null);
|
|
84
106
|
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
|
107
|
+
// Hysteretic twin of `showScrollBottom` (see `ChatTimelineScrollResult`): it
|
|
108
|
+
// engages past `COMPACT_TRIGGER_PX` and releases at
|
|
109
|
+
// `FOLLOW_BOTTOM_THRESHOLD_PX`, so a consumer that resizes itself in
|
|
110
|
+
// response cannot flip the flag back and forth.
|
|
111
|
+
const [readingBack, setReadingBack] = useState(false);
|
|
85
112
|
// True while the viewport is (or should stay) pinned to the tail. A ref, not
|
|
86
113
|
// state: scroll chunks read it every frame and a flip must not re-render.
|
|
87
114
|
const followRef = useRef(true);
|
|
@@ -111,7 +138,12 @@ export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}):
|
|
|
111
138
|
const syncFollowBottom = useCallback(() => {
|
|
112
139
|
const el = scrollRef.current;
|
|
113
140
|
if (!el) return;
|
|
114
|
-
|
|
141
|
+
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
142
|
+
setShowScrollBottom(distance >= FOLLOW_BOTTOM_THRESHOLD_PX);
|
|
143
|
+
// Two thresholds, one direction each: engaged further back, released at
|
|
144
|
+
// the tail. The setter is called with the SAME value on every scroll that
|
|
145
|
+
// does not cross one of them, and React bails out of that render.
|
|
146
|
+
setReadingBack((wasReading) => (wasReading ? distance >= FOLLOW_BOTTOM_THRESHOLD_PX : distance >= COMPACT_TRIGGER_PX));
|
|
115
147
|
}, []);
|
|
116
148
|
|
|
117
149
|
const handleScroll = useCallback(() => {
|
|
@@ -216,6 +248,7 @@ export function useChatTimelineScroll(options: ChatTimelineScrollOptions = {}):
|
|
|
216
248
|
scrollRef,
|
|
217
249
|
contentRef,
|
|
218
250
|
showScrollBottom,
|
|
251
|
+
readingBack,
|
|
219
252
|
followRef,
|
|
220
253
|
jumpCountRef,
|
|
221
254
|
jumpActiveRef,
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether the viewport is at least `px` wide, subscribed.
|
|
8
|
+
*
|
|
9
|
+
* The phone header decides from this whether its controls fit in the row or
|
|
10
|
+
* have to fold into a `⋯` menu, and that decision cannot be a CSS breakpoint:
|
|
11
|
+
* the row's contents are COMPONENTS (a reload button, a sign-out control that
|
|
12
|
+
* only exists when a password is set, plugin header slots), and a class that
|
|
13
|
+
* hides one of them leaves it mounted and focusable while invisible. A hook
|
|
14
|
+
* returns a boolean the caller branches on, so a folded control is not
|
|
15
|
+
* rendered at all.
|
|
16
|
+
*
|
|
17
|
+
* Subscribed rather than read once, for the reason `useTouchDevice` is: a phone
|
|
18
|
+
* rotates, a desktop window is dragged, and a split-screen tablet changes width
|
|
19
|
+
* without a reload.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
23
|
+
|
|
24
|
+
export function useViewportAtLeast(px: number): boolean {
|
|
25
|
+
const query = `(min-width: ${px}px)`;
|
|
26
|
+
const [matches, setMatches] = useState(() =>
|
|
27
|
+
typeof window === 'undefined' ? false : window.matchMedia(query).matches,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const media = window.matchMedia(query);
|
|
32
|
+
const update = () => setMatches(media.matches);
|
|
33
|
+
// The query can change while the effect is not subscribed — a render that
|
|
34
|
+
// happened before the media query existed — so read it once on the way in.
|
|
35
|
+
update();
|
|
36
|
+
media.addEventListener('change', update);
|
|
37
|
+
return () => media.removeEventListener('change', update);
|
|
38
|
+
}, [query]);
|
|
39
|
+
|
|
40
|
+
return matches;
|
|
41
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
androidDownloadLabel,
|
|
10
|
+
parseAndroidRelease,
|
|
11
|
+
shouldOfferAndroidDownload,
|
|
12
|
+
type AndroidDownload,
|
|
13
|
+
} from '@/client/hooks/ui/updates/android-download';
|
|
14
|
+
|
|
15
|
+
/** The published release every case compares against. */
|
|
16
|
+
const RELEASE = {
|
|
17
|
+
url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/kabmo-android-v1.2.3.apk',
|
|
18
|
+
latest: '1.2.3',
|
|
19
|
+
releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const UPDATE: AndroidDownload = { ...RELEASE, kind: 'update' };
|
|
23
|
+
const INSTALL: AndroidDownload = { ...RELEASE, kind: 'install' };
|
|
24
|
+
|
|
25
|
+
describe('parseAndroidRelease', () => {
|
|
26
|
+
test('reads a complete payload', () => {
|
|
27
|
+
expect(parseAndroidRelease(RELEASE)).toEqual(RELEASE);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('refuses a payload with no asset', () => {
|
|
31
|
+
// The route answers this on a failed probe. A version without a URL would be
|
|
32
|
+
// a button that downloads nothing.
|
|
33
|
+
expect(parseAndroidRelease({ url: null, latest: '1.2.3', releaseUrl: null })).toBeNull();
|
|
34
|
+
expect(parseAndroidRelease({ url: 'https://x/a.apk', latest: null, releaseUrl: null })).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('refuses a non-object', () => {
|
|
38
|
+
expect(parseAndroidRelease(null)).toBeNull();
|
|
39
|
+
expect(parseAndroidRelease('nope')).toBeNull();
|
|
40
|
+
expect(parseAndroidRelease({})).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('keeps a missing releaseUrl as null', () => {
|
|
44
|
+
expect(parseAndroidRelease({ url: 'https://x/a.apk', latest: '1.0.0' })?.releaseUrl).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('the offer inside the installed app', () => {
|
|
49
|
+
test('offers an UPDATE when a newer APK is published', () => {
|
|
50
|
+
expect(shouldOfferAndroidDownload('1.2.2', true, RELEASE)).toEqual(UPDATE);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('offers nothing when the app is already current', () => {
|
|
54
|
+
expect(shouldOfferAndroidDownload('1.2.3', true, RELEASE)).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('offers nothing when the app is ahead', () => {
|
|
58
|
+
// A sideloaded build newer than the published asset: downloading it would be
|
|
59
|
+
// a downgrade.
|
|
60
|
+
expect(shouldOfferAndroidDownload('1.3.0', true, RELEASE)).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('offers nothing when the probe failed', () => {
|
|
64
|
+
expect(shouldOfferAndroidDownload('1.0.0', true, null)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('the offer to an Android browser', () => {
|
|
69
|
+
test('offers an INSTALL, because there is no installed version to compare', () => {
|
|
70
|
+
// The reported gap: a reader on Chrome or Brave for Android was told
|
|
71
|
+
// nothing, and the shell is exactly what they do not know exists.
|
|
72
|
+
expect(shouldOfferAndroidDownload(null, true, RELEASE)).toEqual(INSTALL);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('offers an install even when the published APK is older than some build', () => {
|
|
76
|
+
// Nothing is installed here, so "newer than" has no left-hand side — the
|
|
77
|
+
// published asset is simply what the button names.
|
|
78
|
+
expect(shouldOfferAndroidDownload(undefined, true, { ...RELEASE, latest: '0.0.1' })).toEqual({
|
|
79
|
+
...INSTALL,
|
|
80
|
+
latest: '0.0.1',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('offers nothing when no APK is published', () => {
|
|
85
|
+
expect(shouldOfferAndroidDownload(null, true, null)).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('everywhere else', () => {
|
|
90
|
+
test('a desktop browser is offered nothing', () => {
|
|
91
|
+
// An APK cannot be installed there, and the app's own update badge already
|
|
92
|
+
// covers the server.
|
|
93
|
+
expect(shouldOfferAndroidDownload(null, false, RELEASE)).toBeNull();
|
|
94
|
+
expect(shouldOfferAndroidDownload(null, null, RELEASE)).toBeNull();
|
|
95
|
+
expect(shouldOfferAndroidDownload(null, undefined, RELEASE)).toBeNull();
|
|
96
|
+
expect(shouldOfferAndroidDownload('', false, RELEASE)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('the shell token alone is enough, even if the wider flag is missing', () => {
|
|
100
|
+
// `isAndroid` is derived from the same UA, so a stale bootstrap that predates
|
|
101
|
+
// the field must not silently drop the update offer.
|
|
102
|
+
expect(shouldOfferAndroidDownload('1.2.2', false, RELEASE)).toEqual(UPDATE);
|
|
103
|
+
expect(shouldOfferAndroidDownload('1.2.2', undefined, RELEASE)).toEqual(UPDATE);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('androidDownloadLabel', () => {
|
|
108
|
+
test('names the verb the reader needs', () => {
|
|
109
|
+
expect(androidDownloadLabel(UPDATE)).toBe('Update Kabmo 1.2.3 for Android');
|
|
110
|
+
expect(androidDownloadLabel(INSTALL)).toBe('Install Kabmo 1.2.3 for Android');
|
|
111
|
+
});
|
|
112
|
+
});
|