@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
|
@@ -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
|
+
});
|