@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.
Files changed (127) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
  6. package/package.json +2 -1
  7. package/public/kabmo-wordmark.svg +31 -0
  8. package/src/cli/lib/commands/update.js +17 -2
  9. package/src/cli/lib/commands/update.test.js +36 -0
  10. package/src/client/bootstrap.d.ts +8 -0
  11. package/src/client/components/auth/login-screen/index.test.tsx +93 -0
  12. package/src/client/components/auth/login-screen/index.tsx +43 -22
  13. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  15. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  16. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  17. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  18. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  19. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  20. package/src/client/components/common/batch-selection/types.ts +42 -0
  21. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  22. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  23. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  24. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  25. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  26. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  27. package/src/client/components/common/header-controls/index.tsx +53 -4
  28. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  29. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  30. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  31. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  32. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  33. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  34. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  35. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  36. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  37. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
  39. package/src/client/components/layout/session-sidebar/index.tsx +92 -0
  40. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  41. package/src/client/components/mobile/SessionSidebar.tsx +83 -0
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  43. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  44. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  45. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  46. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  47. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
  48. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
  49. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  53. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  54. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  55. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  59. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  60. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  65. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  66. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  67. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  68. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  69. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  70. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  72. package/src/client/hooks/chat/timeline/index.ts +2 -1
  73. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  74. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  75. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  76. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  77. package/src/client/hooks/ui/media-query.ts +41 -0
  78. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  79. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  80. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  81. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  82. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  83. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  84. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  85. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  86. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  87. package/src/client/lib/native/back.test.ts +108 -0
  88. package/src/client/lib/native/back.ts +60 -0
  89. package/src/client/main.tsx +6 -0
  90. package/src/client/tailwind.css +6 -0
  91. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  92. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  93. package/src/server/lib/fs/git-publish.test.ts +96 -0
  94. package/src/server/lib/fs/git-publish.ts +35 -3
  95. package/src/server/lib/fs/listing.test.ts +86 -0
  96. package/src/server/lib/fs/listing.ts +20 -6
  97. package/src/server/lib/lifecycle/restart.ts +52 -14
  98. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  99. package/src/server/lib/updates/android-release.test.ts +92 -0
  100. package/src/server/lib/updates/android-release.ts +148 -0
  101. package/src/server/lib/updates/apply.ts +8 -0
  102. package/src/server/lib/updates/install-steps.ts +81 -0
  103. package/src/server/lib/updates/install.ts +19 -67
  104. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  105. package/src/server/lib/updates/installed-cli.ts +139 -0
  106. package/src/server/lib/updates/omp.ts +1 -1
  107. package/src/server/plugins/ssr.ts +9 -2
  108. package/src/server/routes/folders/batch-delete.ts +68 -0
  109. package/src/server/routes/folders/index.ts +4 -0
  110. package/src/server/routes/fs/read.ts +24 -9
  111. package/src/server/routes/sessions/batch-delete.test.ts +142 -0
  112. package/src/server/routes/sessions/batch-delete.ts +60 -0
  113. package/src/server/routes/sessions/delete.ts +13 -51
  114. package/src/server/routes/sessions/index.ts +5 -0
  115. package/src/server/routes/updates/android.ts +22 -0
  116. package/src/server/routes/updates/apply.ts +1 -1
  117. package/src/server/routes/updates/index.ts +2 -0
  118. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  119. package/src/shared/lib/updates/shell-ua.ts +36 -0
  120. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  121. package/src/shared/lib/workspace/batch-select.ts +86 -0
  122. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  123. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  124. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  125. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  126. package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
  127. package/dist/client/static/js/index-x9cehh1a.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
- * Runs against a real DOM (happy-dom) because these are Preact hooks; geometry
21
- * is faked on the container, since happy-dom does no layout.
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 { Window } from 'happy-dom';
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 useChatTimelineScroll: typeof UseScroll;
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
- win = new Window({ url: 'http://localhost' });
158
- for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event'] as const) {
159
- (globalThis as Record<string, unknown>)[key] = (win as unknown as Record<string, unknown>)[key];
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
- for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'ResizeObserver']) {
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(async () => { scroll().jumpToBottom('instant'); });
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(async () => { scroll().scrollToBottom('smooth'); });
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(async () => { scroll().jumpToBottom('instant'); });
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(async () => { scroll().jumpToBottom('instant'); });
108
+ await p.act(() => { scroll().jumpToBottom('instant'); });
251
109
  expect(scroll().jumpActiveRef.current).toBe(true);
252
110
 
253
- await act(async () => { fakeTimers.advanceTimersByTime(1000); });
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(async () => { observer.fire(); });
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(async () => { StubResizeObserver.latest().fire(); });
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(async () => { observer.fire(); });
309
- geometry.height += 800;
310
- await act(async () => { observer.fire(); });
311
- geometry.height += 800;
312
- await act(async () => { observer.fire(); });
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
- setShowScrollBottom(el.scrollHeight - el.scrollTop - el.clientHeight >= FOLLOW_BOTTOM_THRESHOLD_PX);
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,73 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import {
9
+ parseAndroidRelease,
10
+ shouldOfferAndroidDownload,
11
+ type AndroidDownload,
12
+ } from '@/client/hooks/ui/updates/android-download';
13
+
14
+ /**
15
+ * The download button is a promise that a newer APK exists. These are the four
16
+ * ways that promise can be wrong, and each must render nothing.
17
+ */
18
+ const RELEASE: AndroidDownload = {
19
+ url: 'https://github.com/kabmo-labs/kabmo/releases/download/v1.2.3/kabmo-android-v1.2.3.apk',
20
+ latest: '1.2.3',
21
+ releaseUrl: 'https://github.com/kabmo-labs/kabmo/releases/tag/v1.2.3',
22
+ };
23
+
24
+ describe('parseAndroidRelease', () => {
25
+ test('reads a complete payload', () => {
26
+ expect(parseAndroidRelease(RELEASE)).toEqual(RELEASE);
27
+ });
28
+
29
+ test('refuses a payload with no asset', () => {
30
+ // The route answers this on a failed probe. A version without a URL would be
31
+ // a button that downloads nothing.
32
+ expect(parseAndroidRelease({ url: null, latest: '1.2.3', releaseUrl: null })).toBeNull();
33
+ expect(parseAndroidRelease({ url: 'https://x/a.apk', latest: null, releaseUrl: null })).toBeNull();
34
+ });
35
+
36
+ test('refuses a non-object', () => {
37
+ expect(parseAndroidRelease(null)).toBeNull();
38
+ expect(parseAndroidRelease('nope')).toBeNull();
39
+ expect(parseAndroidRelease({})).toBeNull();
40
+ });
41
+
42
+ test('keeps a missing releaseUrl as null', () => {
43
+ expect(parseAndroidRelease({ url: 'https://x/a.apk', latest: '1.0.0' })?.releaseUrl).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe('shouldOfferAndroidDownload', () => {
48
+ test('offers a newer release to the app', () => {
49
+ expect(shouldOfferAndroidDownload('1.2.2', RELEASE)).toEqual(RELEASE);
50
+ });
51
+
52
+ test('offers nothing to a browser', () => {
53
+ // No shell version means the page is not the APK; a browser has no app to
54
+ // update, and the app's own update badge already covers the server.
55
+ expect(shouldOfferAndroidDownload(null, RELEASE)).toBeNull();
56
+ expect(shouldOfferAndroidDownload(undefined, RELEASE)).toBeNull();
57
+ expect(shouldOfferAndroidDownload('', RELEASE)).toBeNull();
58
+ });
59
+
60
+ test('offers nothing when the app is already current', () => {
61
+ expect(shouldOfferAndroidDownload('1.2.3', RELEASE)).toBeNull();
62
+ });
63
+
64
+ test('offers nothing when the app is ahead', () => {
65
+ // A sideloaded build newer than the published asset: downloading it would be
66
+ // a downgrade.
67
+ expect(shouldOfferAndroidDownload('1.3.0', RELEASE)).toBeNull();
68
+ });
69
+
70
+ test('offers nothing when the probe failed', () => {
71
+ expect(shouldOfferAndroidDownload('1.0.0', null)).toBeNull();
72
+ });
73
+ });