@kabmo-labs/kabmo-ui 0.0.0-stage → 0.0.1

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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The host services the kit reads, and the seam that installs them.
8
+ *
9
+ * `@kabmo-labs/kabmo-ui` is a published package, so it cannot import Kabmo's own
10
+ * source. The host therefore INJECTS its services once at boot via
11
+ * `configureUiKit`, and every hook in this package reads them from here.
12
+ *
13
+ * Each field is something a plugin genuinely cannot do itself: read the
14
+ * Kabmo's per-session store, know the live palette, resolve the active
15
+ * workspace, or render Kabmo's markdown pipeline. Anything a plugin could
16
+ * read from the DOM it should read from the DOM.
17
+ */
18
+
19
+ import type { ComponentType } from 'preact';
20
+ import type { SidebarThread, SidebarThreadActions } from '@kabmo-labs/kabmo-sdk';
21
+
22
+ /** What the host seeds every plugin component with. */
23
+ export interface PanelContext {
24
+ sessionId: string | null;
25
+ workspacePath: string | null;
26
+ theme: string;
27
+ }
28
+
29
+ /**
30
+ * The props a panel BODY is rendered with.
31
+ *
32
+ * A built-in view and a plugin panel receive the SAME shape from the same
33
+ * catalog, so this type is the contract both sides compile against. Every field
34
+ * but the three the layout always knows is optional: a view ignores what it does
35
+ * not use, and a plugin registered for the right panel may ignore all of them
36
+ * and read `usePanelInfo()` instead.
37
+ */
38
+ export interface PanelHostProps {
39
+ /** The active session id, or null. */
40
+ sessionId?: string | null;
41
+ /** False when the view has nothing to read — no workspace, or it is hidden. */
42
+ enabled: boolean;
43
+ /** True while this is the view on screen. */
44
+ active: boolean;
45
+ /** Bumped after a write, so a tree/list view re-reads. */
46
+ refreshKey: number;
47
+ /** The active workspace root, when there is one. */
48
+ rootPath?: string;
49
+ onRefresh?: () => void;
50
+ onOpenFile?: (file: unknown) => void;
51
+ onClose?: () => void;
52
+ /** The phone's drawer hides a view's own header; the desktop stack does not. */
53
+ showHeader?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Props for Kabmo's markdown renderer, injected as a component.
58
+ *
59
+ * The renderer is NOT bundled into the kit: it pulls Kabmo's own marked /
60
+ * KaTeX / mermaid / Shiki pipeline and the host's file-opening and clipboard
61
+ * hooks, which is a large tree a plugin must not carry twice. The host hands the
62
+ * kit the component instead, so a wiki page or a plan reads exactly like the
63
+ * chat timeline without the kit owning any of that machinery.
64
+ */
65
+ export interface HostMarkdownProps {
66
+ content: string;
67
+ className?: string;
68
+ }
69
+
70
+ /** The host's own services, injected once. */
71
+ export interface UiKitServices {
72
+ /** The current panel context. */
73
+ context(): PanelContext;
74
+ /** Subscribe to context changes (a new session, a palette switch). */
75
+ subscribe(listener: (context: PanelContext) => void): () => void;
76
+ /** Read one per-session value. */
77
+ getSessionValue(sessionId: string | null, key: string): string | null;
78
+ /** Write one per-session value. */
79
+ setSessionValue(sessionId: string | null, key: string, value: string): void;
80
+ /**
81
+ * Subscribe to writes of ONE per-session value, from any component.
82
+ *
83
+ * Optional so a host built before this seam still runs a newer kit — the
84
+ * hook degrades to read-on-mount, which is what every host did.
85
+ *
86
+ * It exists because `getSessionValue` alone makes two readers of one key
87
+ * disagree: a component reads the store once and then holds its own copy, so
88
+ * a field that WRITES a value and a readout that DISPLAYS it are two private
89
+ * copies of the same fact, and the readout keeps the value it read at mount
90
+ * forever.
91
+ */
92
+ subscribeSessionValue?(sessionId: string | null, key: string, listener: () => void): () => void;
93
+ /**
94
+ * Typed per-session state, for a view that stores more than a string.
95
+ *
96
+ * `useSessionValue` covers the common case (a note, a path); a collapsed-phase
97
+ * map or a picked file is not a string, and JSON-encoding it into one would
98
+ * make every reader parse it back. These three are the same store, untyped by
99
+ * the kit and narrowed by the caller.
100
+ *
101
+ * Optional so an older host still runs: `useSessionState` then answers its
102
+ * fallback and never persists, which is honest about a host that cannot.
103
+ */
104
+ getSessionJson?<T>(sessionId: string | null, key: string): T | undefined;
105
+ setSessionJson?(sessionId: string | null, key: string, value: unknown): void;
106
+ subscribeSessionJson?(sessionId: string | null, key: string, listener: () => void): () => void;
107
+ /** Read a text file inside the active workspace. Rejects with the reason. */
108
+ readWorkspaceFile(workspacePath: string | null, relativePath: string): Promise<string>;
109
+ /** The Kabmo's markdown renderer, so a plugin does not bundle a second one. */
110
+ markdown?(): ComponentType<HostMarkdownProps> | null;
111
+
112
+ // -------------------------------------------------------------------------
113
+ // The participant seam: what lets a plugin act on the conversation, not only
114
+ // draw beside it.
115
+ // -------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Call one of a plugin's server methods.
119
+ *
120
+ * `pluginId` is the FIRST argument and the kit supplies it from the Preact
121
+ * context of the subtree the call was made in — so a plugin never names its
122
+ * own namespace, and a component rendered for plugin A cannot reach plugin B.
123
+ * The host is the one party that cannot be wrong about which plugin it is
124
+ * running.
125
+ *
126
+ * The result is the WIRE shape the route answers with: `{ ok: true, value }`
127
+ * or `{ ok: false, error }` at HTTP 200, because a plugin's own refusal is a
128
+ * value rather than a transport failure. A hook that flattened the two would
129
+ * turn "the plugin said no" into "the network broke", which is the one
130
+ * distinction the route exists to preserve.
131
+ */
132
+ callRpc?(pluginId: string, method: string, input: unknown): Promise<PluginRpcOutcome>;
133
+ /** Subscribe to a plugin's own realtime topic. Returns an unsubscribe. */
134
+ subscribePluginTopic?(pluginId: string, listener: (value: unknown) => void): () => void;
135
+ /** The plugin's topic value as it stands, for a first render before a frame. */
136
+ readPluginTopic?(pluginId: string): unknown;
137
+ /** Ask the server for a fresh snapshot of the plugin's topic. */
138
+ refreshPluginTopic?(pluginId: string): void;
139
+ /** Whether the value on hand is not known to be current. */
140
+ pluginTopicStale?(pluginId: string): boolean;
141
+
142
+ /**
143
+ * The composer's live state and its writes.
144
+ *
145
+ * `write` goes through the SAME state the chat owns, not the DOM: the
146
+ * composer is a controlled textarea, so a DOM write is reverted by the next
147
+ * keystroke and the plugin's edit vanishes with no error.
148
+ */
149
+ composer?(): ComposerService;
150
+ /** Subscribe to composer changes (a keystroke, a run starting, a send). */
151
+ subscribeComposer?(listener: (state: ComposerState) => void): () => void;
152
+
153
+ /**
154
+ * The sidebar's sessions, as the host's own sidebar holds them.
155
+ *
156
+ * A projection rather than a second fetch: a plugin sees exactly what the
157
+ * sidebar beside it sees, including a run in flight, so the two cannot
158
+ * disagree. Returns the current value and a subscription for pushes.
159
+ */
160
+ sidebarThreads?(): { value: SidebarThread[]; stale: boolean };
161
+ subscribeSidebarThreads?(listener: (state: { value: SidebarThread[]; stale: boolean }) => void): () => void;
162
+ /** Ask the host for a fresh sidebar snapshot. */
163
+ refreshSidebarThreads?(): void;
164
+ /**
165
+ * The session actions, injected as a service.
166
+ *
167
+ * The host owns the writes because they go through its own routes — there is
168
+ * no second session store, so a plugin's rename and the sidebar's own inline
169
+ * editor cannot drift.
170
+ */
171
+ sidebarActions?(): SidebarThreadActions;
172
+
173
+ // -------------------------------------------------------------------------
174
+ // The repo scope: the nested repository the right-panel views operate on.
175
+ // -------------------------------------------------------------------------
176
+
177
+ /**
178
+ * The nested-repo scope the workspace panels share, as it stands.
179
+ *
180
+ * The host owns this value: it is a path RELATIVE to the active workspace
181
+ * root (`projects/api`), validated by the host's own routes, and the Files,
182
+ * Search, Source Control and Terminal panels all read and write the SAME
183
+ * slot. A plugin that reads it therefore describes the tree those panels
184
+ * describe, and a write moves them — which is the whole point of exposing it
185
+ * rather than letting a plugin keep its own picker.
186
+ *
187
+ * It is a SERVICE rather than `useSessionState('workspace.activeRepo')`
188
+ * because the kit's session services carry a `plugin.` prefix (a plugin's
189
+ * keys live in its own namespace), so the shared slot is unreachable from
190
+ * them. And the stored value is a `{ root, repo }` PAIR, not a bare string:
191
+ * the root it was picked under is part of its meaning, and resolving it here
192
+ * is what keeps a plugin from showing another workspace's repo.
193
+ *
194
+ * Optional, like every additive service: a host built before this seam still
195
+ * runs a newer kit, and the hook then reports no scope rather than throwing.
196
+ */
197
+ repoScope?(): RepoScope;
198
+ /** Set the shared pick. `repo` is root-relative; `'.'` means the root itself. */
199
+ setRepoScope?(repo: string): void;
200
+ /**
201
+ * Subscribe to scope changes. Returns an unsubscribe.
202
+ *
203
+ * Fires for a pick AND for a session or workspace change, because the slot is
204
+ * per-session: switching sessions changes the effective scope while the stored
205
+ * key is untouched, so a subscriber wired only to the key would keep reporting
206
+ * the previous session's repo.
207
+ */
208
+ subscribeRepoScope?(listener: () => void): () => void;
209
+ /** Force a fresh nested-repo walk for the current root (the picker's ⟳). */
210
+ rescanRepoScope?(): void;
211
+ }
212
+
213
+ /**
214
+ * One row of the repo picker.
215
+ *
216
+ * The LABELS travel with the row rather than being derived in the kit, because
217
+ * the naming rule is the host's: `.` reads as the workspace folder's own name,
218
+ * a list row keeps its whole root-relative path (so `jns6_5/token` and
219
+ * `jns7/token` are distinguishable), and a trigger truncates to the last
220
+ * segment. The kit is a published package that cannot import the host's
221
+ * `repo-label` module, and a second copy of those three rules is exactly how one
222
+ * repo comes to be labelled two ways in two places.
223
+ */
224
+ export interface RepoScopeRow {
225
+ /** The root-relative path — what a selection sends back. `'.'` is the root. */
226
+ repo: string;
227
+ /** What the row reads in the list. */
228
+ label: string;
229
+ /** The short form for the trigger, where the space is a few characters. */
230
+ short: string;
231
+ }
232
+
233
+ /**
234
+ * The nested-repo scope, as a plugin sees it.
235
+ *
236
+ * `rows` always contains the root itself (`'.'` first), because browsing the
237
+ * workspace root is legitimate even when it is not a repository — a folder of
238
+ * nested repos is a normal workspace.
239
+ */
240
+ export interface RepoScope {
241
+ /** The active workspace root, or null when there is none. */
242
+ root: string | null;
243
+ /** The picked repo, root-relative. `'.'` is the root. */
244
+ repo: string;
245
+ /** Every choice, `'.'` first, then discovery's order. */
246
+ rows: RepoScopeRow[];
247
+ /** Discovery is still running — the picker's refresh spins. */
248
+ scanning: boolean;
249
+ /** False until the session's stored state has loaded, so a picker can hold. */
250
+ ready: boolean;
251
+ }
252
+
253
+ /**
254
+ * One RPC call's failure, as the wire carries it.
255
+ *
256
+ * `issues` is the schema's own report, passed through so a plugin can render
257
+ * which argument was wrong rather than only that something was.
258
+ */
259
+ export interface PluginRpcError {
260
+ code: string;
261
+ message: string;
262
+ issues?: readonly { message: string; path?: readonly (string | number)[] }[];
263
+ }
264
+
265
+ /**
266
+ * One RPC call's outcome, mirroring the wire exactly.
267
+ */
268
+ export type PluginRpcOutcome = { ok: true; value: unknown } | { ok: false; error: PluginRpcError };
269
+
270
+ /** What a plugin may read of the composer. */
271
+ export interface ComposerState {
272
+ /** The draft as it stands. */
273
+ text: string;
274
+ isEmpty: boolean;
275
+ /** A run is in flight, so a send-like action should disable itself. */
276
+ isSubmitting: boolean;
277
+ /** Where this composer writes: a session's chat, or a not-yet-created one. */
278
+ scope: { kind: 'session' | 'new'; sessionId: string | null };
279
+ }
280
+
281
+ /** The composer's writes, for a plugin that drives it rather than reading it. */
282
+ export interface ComposerService extends ComposerState {
283
+ /** Replace the draft. */
284
+ set(text: string): void;
285
+ /** Insert at the caret, leaving the rest of the draft alone. */
286
+ insert(text: string): void;
287
+ focus(): void;
288
+ }
289
+
290
+ let services: UiKitServices | null = null;
291
+
292
+ /** Called ONCE by the host at boot. A plugin must never call this. */
293
+ export function configureUiKit(next: UiKitServices): void {
294
+ services = next;
295
+ }
296
+
297
+ export function requireServices(): UiKitServices {
298
+ if (!services) {
299
+ throw new Error(
300
+ 'The Kabmo UI kit has no host services — this component must be rendered by Kabmo app.',
301
+ );
302
+ }
303
+ return services;
304
+ }
305
+
306
+ /** The host services as they stand, or null outside the app. */
307
+ export function uiKitServices(): UiKitServices | null {
308
+ return services;
309
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `useSessionValue` and the components that read the same slot.
8
+ *
9
+ * The defect this pins: the hook read the store ONCE per mount and then held a
10
+ * private copy, so two components reading one key never saw each other's
11
+ * writes. A field that WRITES a value and a readout that DISPLAYS it therefore
12
+ * disagreed permanently — the readout kept whatever it read at mount while the
13
+ * field showed what the user had typed. That is the bundled example plugin's
14
+ * own shape (a note field and a character count of that note), so the plugin
15
+ * contradicted itself on screen.
16
+ *
17
+ * Two rules the fix must keep:
18
+ *
19
+ * - a write by ANOTHER component re-reads the slot and re-renders;
20
+ * - a write by THIS component does NOT: its own state is the newer truth until
21
+ * the debounce lands, and re-reading would fight the user's keystrokes.
22
+ *
23
+ * Rendered with `h()` (no JSX) against happy-dom, with a fake services seam —
24
+ * the kit is a published package and must not reach into the host's source.
25
+ */
26
+
27
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
28
+ import { Window } from 'happy-dom';
29
+ import { h, render } from 'preact';
30
+ import { act } from 'preact/test-utils';
31
+ import { configureUiKit, useSessionValue, type PanelContext, type UiKitServices } from '@kabmo-labs/kabmo-ui';
32
+
33
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
34
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
35
+
36
+ const CONTEXT: PanelContext = { sessionId: 's1', workspacePath: '/ws', theme: 'paper' };
37
+
38
+ /** The store behind the fake seam: one slot per session+key, as the host has. */
39
+ function makeServices() {
40
+ const store = new Map<string, string>();
41
+ const listeners = new Map<string, Set<() => void>>();
42
+ const slotKey = (key: string) => `${CONTEXT.sessionId}:${key}`;
43
+
44
+ const services: UiKitServices = {
45
+ context: () => CONTEXT,
46
+ subscribe: () => () => {},
47
+ getSessionValue: (_sessionId, key) => store.get(slotKey(key)) ?? null,
48
+ setSessionValue: (_sessionId, key, value) => {
49
+ store.set(slotKey(key), value);
50
+ for (const listener of [...(listeners.get(slotKey(key)) ?? [])]) listener();
51
+ },
52
+ subscribeSessionValue: (_sessionId, key, listener) => {
53
+ const bucket = listeners.get(slotKey(key)) ?? new Set();
54
+ bucket.add(listener);
55
+ listeners.set(slotKey(key), bucket);
56
+ return () => bucket.delete(listener);
57
+ },
58
+ readWorkspaceFile: async () => '',
59
+ };
60
+
61
+ /** What a DIFFERENT component's write looks like, reaching every reader. */
62
+ const externalWrite = (key: string, value: string) => services.setSessionValue(CONTEXT.sessionId, key, value);
63
+
64
+ return { services, store, externalWrite };
65
+ }
66
+
67
+ let container: HTMLElement;
68
+
69
+ beforeEach(() => {
70
+ const win = new Window({ url: 'http://localhost' });
71
+ const target = globalThis as unknown as Record<string, unknown>;
72
+ for (const key of DOM_GLOBALS) {
73
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
74
+ target[key] = (win as unknown as Record<string, unknown>)[key];
75
+ }
76
+ container = document.createElement('div') as unknown as HTMLElement;
77
+ document.body.appendChild(container as never);
78
+ });
79
+
80
+ afterEach(() => {
81
+ render(null, container);
82
+ container.remove();
83
+ const target = globalThis as unknown as Record<string, unknown>;
84
+ for (const key of DOM_GLOBALS) {
85
+ if (key in nativeGlobals) target[key] = nativeGlobals[key];
86
+ else delete target[key];
87
+ }
88
+ });
89
+
90
+ describe('useSessionValue', () => {
91
+ test('a readout follows a write made by ANOTHER component', async () => {
92
+ const { services, externalWrite } = makeServices();
93
+ configureUiKit(services);
94
+
95
+ /** Reads the slot without ever writing it — the readout's shape. */
96
+ function Readout() {
97
+ const { value } = useSessionValue('note', 10);
98
+ return h('span', { id: 'readout' }, String((value ?? '').length));
99
+ }
100
+
101
+ await act(async () => {
102
+ render(h(Readout, {}), container);
103
+ });
104
+ expect(container.textContent).toBe('0');
105
+
106
+ // The field component writes; the readout must catch up.
107
+ await act(async () => {
108
+ externalWrite('note', 'hello');
109
+ });
110
+ expect(container.textContent).toBe('5');
111
+ });
112
+
113
+ test('a component mid-edit keeps its own text until the debounce lands', async () => {
114
+ const { services } = makeServices();
115
+ configureUiKit(services);
116
+
117
+ function Field() {
118
+ const { value, update } = useSessionValue('note', 10_000);
119
+ return h('input', { id: 'field', value: value ?? '', onInput: (event: Event) => update((event.target as HTMLInputElement).value) });
120
+ }
121
+
122
+ await act(async () => {
123
+ render(h(Field, {}), container);
124
+ });
125
+
126
+ const field = container.querySelector('#field') as HTMLInputElement;
127
+ await act(async () => {
128
+ field.value = 'typed';
129
+ field.dispatchEvent(new Event('input', { bubbles: true }));
130
+ });
131
+
132
+ // A long debounce: the store still holds nothing, and the field must NOT
133
+ // re-read it — that would erase the keystroke on screen.
134
+ expect(field.value).toBe('typed');
135
+ });
136
+
137
+ test('a write from another component does not mark this field dirty', async () => {
138
+ const { services, externalWrite, store } = makeServices();
139
+ configureUiKit(services);
140
+
141
+ /** A field that would write its value back if an outside write set `saving`. */
142
+ function Field() {
143
+ const { value, update } = useSessionValue('note', 10);
144
+ return h('input', { id: 'field', value: value ?? '', onInput: (event: Event) => update((event.target as HTMLInputElement).value) });
145
+ }
146
+
147
+ await act(async () => {
148
+ render(h(Field, {}), container);
149
+ });
150
+
151
+ await act(async () => {
152
+ externalWrite('note', 'from elsewhere');
153
+ });
154
+
155
+ // The field shows the outside value and has not overwritten it.
156
+ expect((container.querySelector('#field') as HTMLInputElement).value).toBe('from elsewhere');
157
+ const settled = Promise.withResolvers<void>();
158
+ setTimeout(() => settled.resolve(), 40);
159
+ await settled.promise;
160
+ expect(store.get(`${CONTEXT.sessionId}:note`)).toBe('from elsewhere');
161
+ });
162
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The sidebar hooks, driven through the harness fakes.
8
+ *
9
+ * Two properties are what a plugin depends on:
10
+ *
11
+ * - **The read is reactive.** A push from the host reaches a mounted component,
12
+ * so a list does not freeze at what it read at mount.
13
+ * - **A refusal is a VALUE, not a throw.** The host answers `false`; the hook
14
+ * surfaces the reason on `error` and keeps the promise resolvable, because a
15
+ * plugin that had to catch would lose the host's own wording.
16
+ */
17
+
18
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+ import { Window } from 'happy-dom';
20
+ import { h, render } from 'preact';
21
+ import { act } from 'preact/test-utils';
22
+ import { configureUiKit, PluginIdProvider, experimental_useSidebarThreads, experimental_useSidebarThreadActions } from '@kabmo-labs/kabmo-ui';
23
+ import { makeUiKitServices, type FakeUiKit } from '@kabmo-labs/kabmo-sdk/testing';
24
+ import type { SidebarThread } from '@kabmo-labs/kabmo-sdk';
25
+
26
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event'] as const;
27
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
28
+ let window_: Window | null = null;
29
+
30
+ function installDom(): void {
31
+ window_ = new Window();
32
+ const target = globalThis as unknown as Record<string, unknown>;
33
+ const source = window_ as unknown as Record<string, unknown>;
34
+ for (const key of DOM_GLOBALS) {
35
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
36
+ target[key] = source[key];
37
+ }
38
+ }
39
+
40
+ function restoreDom(): void {
41
+ const target = globalThis as unknown as Record<string, unknown>;
42
+ for (const key of DOM_GLOBALS) {
43
+ const value = nativeGlobals[key];
44
+ if (value === undefined) delete target[key];
45
+ else target[key] = value;
46
+ }
47
+ window_ = null;
48
+ }
49
+
50
+ let host: FakeUiKit;
51
+ let container: HTMLDivElement;
52
+
53
+ function thread(id: string, title: string): SidebarThread {
54
+ return { id, folderId: '1', title };
55
+ }
56
+
57
+ beforeEach(() => {
58
+ installDom();
59
+ host = makeUiKitServices();
60
+ configureUiKit(host);
61
+ container = document.createElement('div');
62
+ document.body.appendChild(container);
63
+ });
64
+
65
+ afterEach(() => {
66
+ render(null, container);
67
+ container.remove();
68
+ restoreDom();
69
+ });
70
+
71
+ /** Render one hook inside a plugin's scope, as a host surface does. */
72
+ function mount<T>(use: () => T, pluginId = 'demo'): { get: () => T } {
73
+ let latest: T;
74
+ function Probe() {
75
+ latest = use();
76
+ return null;
77
+ }
78
+ act(() => render(h(PluginIdProvider, { pluginId }, h(Probe, {})), container));
79
+ return { get: () => latest! };
80
+ }
81
+
82
+ describe('experimental_useSidebarThreads', () => {
83
+ test('reads the host’s current list on mount', () => {
84
+ host.sidebarControl.setThreads([thread('s1', 'First'), thread('s2', 'Second')]);
85
+ const sidebar = mount(() => experimental_useSidebarThreads());
86
+
87
+ expect(sidebar.get().threads.map((t) => t.id)).toEqual(['s1', 's2']);
88
+ expect(sidebar.get().isLoading).toBe(false);
89
+ });
90
+
91
+ test('a host push reaches the mounted component', () => {
92
+ host.sidebarControl.setThreads([thread('s1', 'First')]);
93
+ const sidebar = mount(() => experimental_useSidebarThreads());
94
+ expect(sidebar.get().threads).toHaveLength(1);
95
+
96
+ act(() => {
97
+ host.sidebarControl.setThreads([thread('s1', 'First'), thread('s2', 'Added later')]);
98
+ });
99
+
100
+ expect(sidebar.get().threads).toHaveLength(2);
101
+ expect(sidebar.get().threads[1]?.title).toBe('Added later');
102
+ });
103
+
104
+ test('stale is surfaced rather than hidden', () => {
105
+ // A list shown as current when the socket is down is worse than one that
106
+ // says so, so the flag travels with the value.
107
+ const staleHost = makeUiKitServices({ sidebarStale: true, threads: [thread('s1', 'x')] });
108
+ configureUiKit(staleHost);
109
+ const sidebar = mount(() => experimental_useSidebarThreads());
110
+ expect(sidebar.get().stale).toBe(true);
111
+ });
112
+
113
+ test('an older host without the service answers loaded-and-empty', () => {
114
+ // Honest rather than broken: there is nothing to wait for.
115
+ const bare = makeUiKitServices();
116
+ delete (bare as { subscribeSidebarThreads?: unknown }).subscribeSidebarThreads;
117
+ delete (bare as { sidebarThreads?: unknown }).sidebarThreads;
118
+ configureUiKit(bare);
119
+
120
+ const sidebar = mount(() => experimental_useSidebarThreads());
121
+ expect(sidebar.get().threads).toEqual([]);
122
+ expect(sidebar.get().isLoading).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe('experimental_useSidebarThreadActions', () => {
127
+ test('rename reaches the host and answers true', async () => {
128
+ const actions = mount(() => experimental_useSidebarThreadActions());
129
+ let ok = false;
130
+ await act(async () => {
131
+ ok = await actions.get().rename('s1', 'A new name');
132
+ });
133
+
134
+ expect(ok).toBe(true);
135
+ expect(host.sidebarControl.calls).toEqual([{ action: 'rename', args: ['s1', 'A new name'] }]);
136
+ expect(actions.get().error).toBeNull();
137
+ });
138
+
139
+ test('a refusal answers false and surfaces the host’s reason', async () => {
140
+ // The host answers the refusal as a value; a plugin must not have to catch.
141
+ host.sidebarControl.failNext('the name is empty');
142
+ const actions = mount(() => experimental_useSidebarThreadActions());
143
+
144
+ let ok = true;
145
+ await act(async () => {
146
+ ok = await actions.get().rename('s1', '');
147
+ });
148
+
149
+ expect(ok).toBe(false);
150
+ expect(actions.get().error).toBe('the name is empty');
151
+ });
152
+
153
+ test('the reason clears on the next attempt', async () => {
154
+ host.sidebarControl.failNext('nope');
155
+ const actions = mount(() => experimental_useSidebarThreadActions());
156
+ await act(async () => {
157
+ await actions.get().rename('s1', 'x');
158
+ });
159
+ expect(actions.get().error).toBe('nope');
160
+
161
+ await act(async () => {
162
+ await actions.get().rename('s1', 'y');
163
+ });
164
+ expect(actions.get().error).toBeNull();
165
+ });
166
+
167
+ test('archive, markSeen and remove each reach the host', async () => {
168
+ const actions = mount(() => experimental_useSidebarThreadActions());
169
+ await act(async () => {
170
+ await actions.get().setArchived('s1', true);
171
+ await actions.get().markSeen('s1');
172
+ await actions.get().remove('s1');
173
+ });
174
+
175
+ expect(host.sidebarControl.calls.map((c) => c.action)).toEqual(['setArchived', 'markSeen', 'remove']);
176
+ });
177
+
178
+ test('open and openNew reach the host', () => {
179
+ const actions = mount(() => experimental_useSidebarThreadActions());
180
+ act(() => {
181
+ actions.get().open('s9');
182
+ actions.get().openNew({ folderId: '3' });
183
+ });
184
+
185
+ expect(host.sidebarControl.calls).toEqual([
186
+ { action: 'open', args: ['s9'] },
187
+ { action: 'openNew', args: [{ folderId: '3' }] },
188
+ ]);
189
+ });
190
+ });