@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.
Files changed (80) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +285 -285
  3. package/dist/client/static/js/{index-enkbbffs.js → index-8x5j8zrr.js} +671 -671
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/package.json +1 -1
  6. package/src/client/bootstrap.d.ts +17 -0
  7. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  8. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  9. package/src/client/components/common/header-controls/index.tsx +53 -4
  10. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  11. package/src/client/components/layout/session-sidebar/Footer.tsx +24 -2
  12. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  13. package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
  14. package/src/client/components/layout/session-sidebar/index.tsx +8 -0
  15. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  16. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  17. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  18. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +29 -1
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
  20. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
  21. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  22. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  23. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  24. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  25. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  26. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  27. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  28. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  29. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  30. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  31. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  32. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  33. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  34. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  36. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  37. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  38. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  39. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  40. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  41. package/src/client/data/settings/omp-schema.json +89 -4
  42. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  43. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  44. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  45. package/src/client/hooks/chat/timeline/index.ts +2 -1
  46. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  47. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  48. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  49. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  50. package/src/client/hooks/ui/media-query.ts +41 -0
  51. package/src/client/hooks/ui/updates/android-download.test.ts +112 -0
  52. package/src/client/hooks/ui/updates/android-download.ts +143 -0
  53. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  54. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  55. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  56. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  57. package/src/client/lib/native/back.test.ts +108 -0
  58. package/src/client/lib/native/back.ts +60 -0
  59. package/src/client/main.tsx +6 -0
  60. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  61. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  62. package/src/server/lib/fs/git-publish.test.ts +96 -0
  63. package/src/server/lib/fs/git-publish.ts +35 -3
  64. package/src/server/lib/fs/listing.test.ts +86 -0
  65. package/src/server/lib/fs/listing.ts +20 -6
  66. package/src/server/lib/updates/android-release.test.ts +138 -0
  67. package/src/server/lib/updates/android-release.ts +183 -0
  68. package/src/server/plugins/ssr.ts +14 -2
  69. package/src/server/routes/fs/read.ts +24 -9
  70. package/src/server/routes/sessions/batch-delete.test.ts +14 -11
  71. package/src/server/routes/updates/android.ts +22 -0
  72. package/src/server/routes/updates/index.ts +2 -0
  73. package/src/shared/lib/updates/shell-ua.test.ts +121 -0
  74. package/src/shared/lib/updates/shell-ua.ts +56 -0
  75. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  76. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  77. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  78. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  79. package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
  80. package/dist/client/static/js/index-yn17t04b.css +0 -1
@@ -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
+ });