@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.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@kabmo-labs/kabmo-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "Preact hooks and components for Kabmo panel plugins.",
6
+ "exports": {
7
+ ".": "./src/index.tsx",
8
+ "./components": "./src/components.tsx"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "dependencies": {
14
+ "preact": "^10.29.8",
15
+ "@kabmo-labs/kabmo-sdk": "0.0.1"
16
+ },
17
+ "license": "UNLICENSED",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/kabmo-labs/kabmo.git",
21
+ "directory": "packages/kabmo-ui"
22
+ },
23
+ "homepage": "https://github.com/kabmo-labs/kabmo#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/kabmo-labs/kabmo/issues"
26
+ },
27
+ "keywords": [
28
+ "kabmo",
29
+ "panel",
30
+ "plugin",
31
+ "preact",
32
+ "ui"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "engines": {
38
+ "bun": ">=1.4.0"
39
+ }
40
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The panel UI kit.
8
+ *
9
+ * These are ordinary host components, not a sandboxed mini-app's widgets: a
10
+ * plugin renders inside Kabmo's own tree, so the kit uses Kabmo's
11
+ * theme variables and Tailwind utilities directly and looks native in every
12
+ * palette with no CSS bridge.
13
+ *
14
+ * The kit exists so a plugin does not re-invent the same six shapes — a titled
15
+ * panel, a label/value row, a text field, a button, an empty state, a note —
16
+ * and so those shapes stay consistent across plugins the way they are within
17
+ * Kabmo itself.
18
+ *
19
+ * Every component takes its colours from the theme variables (`bg-paper`,
20
+ * `text-ink`, `border-ink/10`), never a literal, which is what keeps a plugin
21
+ * legible in the light and dark palettes without knowing they exist.
22
+ */
23
+
24
+ import type { ComponentChildren } from 'preact';
25
+ import { uiKitServices } from './services';
26
+
27
+ interface PanelProps {
28
+ title: string;
29
+ children: ComponentChildren;
30
+ }
31
+
32
+ /** A panel's outer chrome: a title bar over a scrolling body. */
33
+ export function Panel({ title, children }: PanelProps) {
34
+ return (
35
+ <div class="flex flex-col h-full w-full bg-paper text-ink">
36
+ <div class="flex-shrink-0 border-b border-ink/10 px-3 py-2">
37
+ <span class="text-xs font-semibold text-ink">{title}</span>
38
+ </div>
39
+ <div class="flex-1 min-h-0 overflow-auto p-3">{children}</div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ interface FieldProps {
45
+ label: string;
46
+ value: string;
47
+ hint?: string;
48
+ }
49
+
50
+ /** One labelled fact. The label is a micro-header, the value is machine text. */
51
+ export function Field({ label, value, hint }: FieldProps) {
52
+ return (
53
+ <div class="flex flex-col gap-0.5">
54
+ <span class="text-[10px] uppercase font-mono text-ink/40">{label}</span>
55
+ <span class="text-xs font-mono text-ink break-all">{value}</span>
56
+ {hint ? <span class="text-[11px] text-ink/50">{hint}</span> : null}
57
+ </div>
58
+ );
59
+ }
60
+
61
+ /** A stack of `Field`s. */
62
+ export function FieldList({ children }: { children: ComponentChildren }) {
63
+ return <div class="flex flex-col gap-3">{children}</div>;
64
+ }
65
+
66
+ interface TextAreaFieldProps {
67
+ id: string;
68
+ label: string;
69
+ value: string;
70
+ placeholder?: string;
71
+ hint?: string;
72
+ onInput: (value: string) => void;
73
+ }
74
+
75
+ /**
76
+ * A labelled textarea.
77
+ *
78
+ * Controlled through `onInput` rather than owning its own state, so the caller
79
+ * decides what a keystroke means — the session-persisting hook debounces the
80
+ * write, while a purely local field can keep the value in the caller.
81
+ */
82
+ export function TextAreaField({ id, label, value, placeholder, hint, onInput }: TextAreaFieldProps) {
83
+ return (
84
+ <div class="flex flex-col gap-1">
85
+ <label for={id} class="text-[10px] uppercase font-mono text-ink/40">
86
+ {label}
87
+ </label>
88
+ <textarea
89
+ id={id}
90
+ value={value}
91
+ placeholder={placeholder}
92
+ onInput={(event) => onInput((event.currentTarget as HTMLTextAreaElement).value)}
93
+ class="w-full min-h-20 resize-y bg-canvas border border-ink/15 rounded px-2.5 py-1.5 text-xs font-mono text-ink placeholder-ink/35 focus:outline-none focus:border-ink/40"
94
+ />
95
+ {hint ? <span class="text-[11px] text-ink/50">{hint}</span> : null}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ interface ButtonProps {
101
+ children: ComponentChildren;
102
+ onClick: () => void;
103
+ disabled?: boolean;
104
+ /** The one action a panel wants the user to take. */
105
+ variant?: 'default' | 'primary';
106
+ }
107
+
108
+ /** A button. */
109
+ export function Button({ children, onClick, disabled, variant = 'default' }: ButtonProps) {
110
+ const tone =
111
+ variant === 'primary'
112
+ ? 'bg-ink text-canvas border-ink hover:opacity-90'
113
+ : 'border-ink/15 hover:bg-ink/5 text-ink';
114
+ return (
115
+ <button
116
+ type="button"
117
+ onClick={onClick}
118
+ disabled={disabled}
119
+ class={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs rounded border transition-colors disabled:opacity-40 disabled:cursor-default ${tone}`}
120
+ >
121
+ {children}
122
+ </button>
123
+ );
124
+ }
125
+
126
+ /** Centred placeholder for a panel with nothing to show. */
127
+ export function Empty({ children }: { children: ComponentChildren }) {
128
+ return (
129
+ <div class="h-full flex items-center justify-center px-6 text-center">
130
+ <p class="text-xs text-ink/45 font-mono">{children}</p>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ /** A message. `error` is the only chroma a panel should use. */
136
+ export function Note({ children, tone = 'default' }: { children: ComponentChildren; tone?: 'default' | 'error' }) {
137
+ const colour = tone === 'error' ? 'text-error' : 'text-ink/60';
138
+ return <p class={`text-[11px] ${colour}`}>{children}</p>;
139
+ }
140
+
141
+ interface MarkdownProps {
142
+ content: string;
143
+ className?: string;
144
+ }
145
+
146
+ /**
147
+ * Render markdown through Kabmo's own pipeline.
148
+ *
149
+ * The pipeline itself is not in this package: it is Kabmo's marked /
150
+ * KaTeX / mermaid / Shiki chain plus its file-opening and clipboard hooks, and
151
+ * bundling it here would make every plugin carry a second copy of a large tree.
152
+ * The host injects the component (`UiKitServices.markdown`), so a wiki page or
153
+ * a plan reads exactly like the chat timeline without the kit owning any of it.
154
+ *
155
+ * A host that injects nothing renders the source as plain text rather than
156
+ * failing: a panel showing raw markdown is degraded, not broken.
157
+ */
158
+ export function Markdown({ content, className }: MarkdownProps) {
159
+ const Renderer = uiKitServices()?.markdown?.() ?? null;
160
+ if (!Renderer) return <pre class={`text-xs font-mono whitespace-pre-wrap ${className ?? ''}`}>{content}</pre>;
161
+ return <Renderer content={content} className={className} />;
162
+ }
163
+
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `useKabmoFetch` — the kit's own reader for a plugin panel that reaches the
8
+ * Kabmo's HTTP API.
9
+ *
10
+ * It replaced two hand-rolled copies (`useSessionTodos`, `useSessionPlan`) that
11
+ * had the same three behaviours and the same three bugs waiting to happen, so
12
+ * this file pins the behaviours rather than the call sites (Kabmo's own
13
+ * views have since moved to the realtime socket):
14
+ *
15
+ * - **A late answer never overwrites a newer one.** Switching sessions fires a
16
+ * second request while the first is in flight; without the sequence guard the
17
+ * previous session's payload lands under the new title.
18
+ * - **A new URL DROPS the old payload first.** Not merely re-reads: a stale list
19
+ * visible under a new session's name is the failure a user actually sees.
20
+ * - **Hidden and disabled panels do not poll.** A background tab cannot render
21
+ * the result, and a hidden view's poll is pure waste.
22
+ *
23
+ * Rendered with `h()` (no JSX) against happy-dom.
24
+ */
25
+
26
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
27
+ import { Window } from 'happy-dom';
28
+ import { h, render } from 'preact';
29
+ import { act } from 'preact/test-utils';
30
+ import { configureUiKit, useKabmoFetch, type PanelContext, type UiKitServices } from '@kabmo-labs/kabmo-ui';
31
+
32
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
33
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
34
+
35
+ const CONTEXT: PanelContext = { sessionId: 's1', workspacePath: '/ws', theme: 'paper' };
36
+ const SERVICES: UiKitServices = {
37
+ context: () => CONTEXT,
38
+ subscribe: () => () => {},
39
+ getSessionValue: () => null,
40
+ setSessionValue: () => {},
41
+ readWorkspaceFile: async () => '',
42
+ };
43
+
44
+ /** One fetch call, and the promise its caller is waiting on. */
45
+ interface PendingRequest {
46
+ url: string;
47
+ resolve: (body: unknown, status?: number) => void;
48
+ }
49
+
50
+ let container: HTMLElement;
51
+ let requests: PendingRequest[] = [];
52
+ /** The reads a disabled/hidden hook was expected NOT to make. */
53
+ let calls = 0;
54
+
55
+ function installFetch(): void {
56
+ (globalThis as unknown as Record<string, unknown>).fetch = (input: unknown) => {
57
+ calls += 1;
58
+ const url = String(input);
59
+ return new Promise<Response>((resolve) => {
60
+ requests.push({
61
+ url,
62
+ resolve: (body, status = 200) =>
63
+ resolve(new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })),
64
+ });
65
+ });
66
+ };
67
+ }
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
+ requests = [];
79
+ calls = 0;
80
+ installFetch();
81
+ configureUiKit(SERVICES);
82
+ });
83
+
84
+ afterEach(() => {
85
+ render(null, container);
86
+ container.remove();
87
+ const target = globalThis as unknown as Record<string, unknown>;
88
+ for (const key of DOM_GLOBALS) {
89
+ if (key in nativeGlobals) target[key] = nativeGlobals[key];
90
+ else delete target[key];
91
+ }
92
+ });
93
+
94
+ /** Render the hook and expose what it holds, so a test can read the DOM. */
95
+ function Probe({ url, enabled = true }: { url: string | null; enabled?: boolean }) {
96
+ const { data, isLoading, error } = useKabmoFetch<{ value: number }>(url, { enabled, pollMs: 0 });
97
+ return h(
98
+ 'div',
99
+ { id: 'probe' },
100
+ `${data === null ? 'null' : data.value}|${isLoading ? 'loading' : 'idle'}|${error ?? 'ok'}`,
101
+ );
102
+ }
103
+
104
+ const read = () => (container.querySelector('#probe') as HTMLElement | null)?.textContent ?? '';
105
+
106
+ async function settle(): Promise<void> {
107
+ for (let i = 0; i < 8; i += 1) await act(async () => {});
108
+ }
109
+
110
+ describe('useKabmoFetch', () => {
111
+ test('a late answer from a superseded request never lands', async () => {
112
+ await act(async () => render(h(Probe, { url: '/api/a' }), container));
113
+ await settle();
114
+ expect(requests).toHaveLength(1);
115
+
116
+ // The caller moves on while the first request is still in flight.
117
+ await act(async () => render(h(Probe, { url: '/api/b' }), container));
118
+ await settle();
119
+ expect(requests).toHaveLength(2);
120
+ expect(read()).toBe('null|loading|ok');
121
+
122
+ // The SECOND settles first, then the first arrives late.
123
+ await act(async () => requests[1].resolve({ value: 2 }));
124
+ await settle();
125
+ expect(read()).toStartWith('2|');
126
+
127
+ await act(async () => requests[0].resolve({ value: 1 }));
128
+ await settle();
129
+ // Still B, not A: the stale answer was dropped rather than written.
130
+ expect(read()).toStartWith('2|');
131
+ });
132
+
133
+ test('a new URL drops the previous payload rather than showing it under the new name', async () => {
134
+ await act(async () => render(h(Probe, { url: '/api/a' }), container));
135
+ await settle();
136
+ await act(async () => requests[0].resolve({ value: 1 }));
137
+ await settle();
138
+ expect(read()).toStartWith('1|');
139
+
140
+ await act(async () => render(h(Probe, { url: '/api/b' }), container));
141
+ // Not "1|loading" — the old list must not render under the new session.
142
+ expect(read()).toBe('null|loading|ok');
143
+ });
144
+
145
+ test('a disabled or absent URL makes no request at all', async () => {
146
+ await act(async () => render(h(Probe, { url: '/api/a', enabled: false }), container));
147
+ await settle();
148
+ expect(calls).toBe(0);
149
+
150
+ await act(async () => render(h(Probe, { url: null }), container));
151
+ await settle();
152
+ expect(calls).toBe(0);
153
+ });
154
+
155
+ test('a failed read keeps the reason and the previous payload', async () => {
156
+ await act(async () => render(h(Probe, { url: '/api/a' }), container));
157
+ await settle();
158
+ await act(async () => requests[0].resolve({ value: 7 }));
159
+ await settle();
160
+
161
+ await act(async () => render(h(Probe, { url: '/api/b' }), container));
162
+ await settle();
163
+ await act(async () => requests[1].resolve({ error: 'nope' }, 500));
164
+ await settle();
165
+ expect(read()).toBe('null|idle|nope');
166
+ });
167
+ });
package/src/fetch.ts ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Fetching Kabmo's HTTP API from a plugin panel.
8
+ *
9
+ * A plugin cannot import Kabmo's hooks, so this is the one place it
10
+ * reaches the server: `GET /api/...` with the session's cookie, plus the three
11
+ * behaviours every built-in panel hand-rolled and a plugin would get wrong —
12
+ *
13
+ * - **Polling that pauses while hidden.** A background tab cannot render the
14
+ * result, so each tick would be wasted work (and on mobile it keeps waking the
15
+ * CPU). A visibilitychange back to visible re-reads at once, so the panel is
16
+ * never stale when the user returns.
17
+ * - **A guard against overlapping reads.** A panel's fetcher aborts its previous
18
+ * request whenever a new one starts, so an unguarded tick would cancel every
19
+ * slow read before it could finish and the panel would sit on "loading"
20
+ * forever.
21
+ * - **Event-driven re-reads.** Name the window events your panel should follow
22
+ * and the hook re-reads on each, coalescing a burst through a throttle.
23
+ *
24
+ * The Kabmo's own views do NOT use this — they read the unified realtime
25
+ * socket (`useRealtimeTopic`), which is not part of the kit's surface. This
26
+ * stays for a plugin that reaches an HTTP endpoint directly.
27
+ *
28
+ * A response for a request the user has already moved past is DROPPED: the
29
+ * hook keeps a sequence number, and a late answer never overwrites a newer one.
30
+ */
31
+
32
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
33
+
34
+ /** How often a visible panel re-reads by default. */
35
+ export const DEFAULT_POLL_MS = 5_000;
36
+
37
+ export interface KabmoFetchOptions {
38
+ /** False while the view is hidden: no poll, no event listener. */
39
+ enabled?: boolean;
40
+ /** Poll cadence in ms. `0` disables polling and keeps the event path. */
41
+ pollMs?: number;
42
+ /** Window events that re-read, throttled by `eventThrottleMs`. */
43
+ events?: readonly string[];
44
+ /** Coalescing window for the event path. */
45
+ eventThrottleMs?: number;
46
+ /** Skip the fetch entirely (a request that cannot be built yet). */
47
+ skip?: boolean;
48
+ }
49
+
50
+ export interface KabmoFetchState<T> {
51
+ data: T | null;
52
+ isLoading: boolean;
53
+ /** Set when the last read failed; the previous payload is kept meanwhile. */
54
+ error: string | null;
55
+ /** Re-read now, ignoring the poll cadence. */
56
+ reload: () => void;
57
+ }
58
+
59
+ /**
60
+ * Read a Kabmo API path, re-reading on a poll and on the given events.
61
+ *
62
+ * `url` is a FULL path rather than a builder, because a caller that changes the
63
+ * query — a session switch, a picked plan — must also change the request, and
64
+ * rebuilding the URL is how the hook knows to drop the old answer and read
65
+ * again.
66
+ */
67
+ export function useKabmoFetch<T>(url: string | null, options: KabmoFetchOptions = {}): KabmoFetchState<T> {
68
+ const { enabled = true, pollMs = DEFAULT_POLL_MS, events, eventThrottleMs = 400, skip = false } = options;
69
+
70
+ const [data, setData] = useState<T | null>(null);
71
+ const [isLoading, setIsLoading] = useState(false);
72
+ const [error, setError] = useState<string | null>(null);
73
+
74
+ // Latest url for the fetcher, so a caller that rebuilds it every render does
75
+ // not tear down the poll interval each time.
76
+ const urlRef = useRef(url);
77
+ urlRef.current = url;
78
+ // Guards against a response for a request the user has already left being
79
+ // written into the panel that now shows another.
80
+ const requestRef = useRef(0);
81
+ const active = enabled && !skip && url !== null;
82
+
83
+ const load = useCallback(() => {
84
+ const target = urlRef.current;
85
+ if (!target) return;
86
+ const seq = ++requestRef.current;
87
+ setIsLoading(true);
88
+ fetch(target, { credentials: 'same-origin' })
89
+ .then(async (response) => {
90
+ if (seq !== requestRef.current) return;
91
+ if (!response.ok) {
92
+ const body = (await response.json().catch(() => null)) as { error?: string } | null;
93
+ throw new Error(body?.error ?? `Request failed (${response.status})`);
94
+ }
95
+ const payload = (await response.json()) as T;
96
+ if (seq !== requestRef.current) return;
97
+ setData(payload);
98
+ setError(null);
99
+ })
100
+ .catch((cause: unknown) => {
101
+ if (seq !== requestRef.current) return;
102
+ setError(cause instanceof Error ? cause.message : String(cause));
103
+ })
104
+ .finally(() => {
105
+ if (seq !== requestRef.current) return;
106
+ setIsLoading(false);
107
+ });
108
+ }, []);
109
+
110
+ // A new URL is a new question: drop the old payload so the previous session's
111
+ // answer cannot render under the new title, then read.
112
+ useEffect(() => {
113
+ setData(null);
114
+ setError(null);
115
+ if (active) load();
116
+ }, [url, active, load]);
117
+
118
+ useEffect(() => {
119
+ if (!active || pollMs <= 0) return;
120
+ let visible = typeof document === 'undefined' || document.visibilityState === 'visible';
121
+ let inFlight = false;
122
+ const id = setInterval(() => {
123
+ if (visible && !inFlight) {
124
+ inFlight = true;
125
+ Promise.resolve(load()).finally(() => {
126
+ inFlight = false;
127
+ });
128
+ }
129
+ }, pollMs);
130
+ const onVisibility = () => {
131
+ const next = document.visibilityState === 'visible';
132
+ if (next && !visible) load();
133
+ visible = next;
134
+ };
135
+ document.addEventListener('visibilitychange', onVisibility);
136
+ return () => {
137
+ clearInterval(id);
138
+ document.removeEventListener('visibilitychange', onVisibility);
139
+ };
140
+ }, [active, pollMs, load]);
141
+
142
+ const throttleRef = useRef<number | undefined>(undefined);
143
+ useEffect(() => {
144
+ if (!active || !events || events.length === 0) return;
145
+ const schedule = () => {
146
+ window.clearTimeout(throttleRef.current);
147
+ throttleRef.current = window.setTimeout(load, eventThrottleMs);
148
+ };
149
+ for (const name of events) window.addEventListener(name, schedule);
150
+ return () => {
151
+ for (const name of events) window.removeEventListener(name, schedule);
152
+ window.clearTimeout(throttleRef.current);
153
+ };
154
+ }, [active, events, eventThrottleMs, load]);
155
+
156
+ useEffect(() => () => window.clearTimeout(throttleRef.current), []);
157
+
158
+ return { data, isLoading, error, reload: load };
159
+ }
160
+
161
+ /**
162
+ * @deprecated Use `useKabmoFetch`.
163
+ *
164
+ * A published package's export is what a plugin bundle was built against, so the
165
+ * old names stay as aliases of the SAME implementation rather than a copy.
166
+ */
167
+ export const useChamberFetch = useKabmoFetch;
168
+
169
+ /** @deprecated Use `KabmoFetchOptions`. */
170
+ export type ChamberFetchOptions = KabmoFetchOptions;
171
+
172
+ /** @deprecated Use `KabmoFetchState`. */
173
+ export type ChamberFetchState<T> = KabmoFetchState<T>;
package/src/index.tsx ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The UI kit a plugin renders with, and the host services it reads through.
8
+ *
9
+ * Everything here runs IN THE HOST'S TREE — same Preact instance, same
10
+ * document, same theme — so the kit is plain Preact components, not a
11
+ * message-passing client. What it still must not do is reach into the host's
12
+ * own source: this is a published package, so the host INJECTS its services
13
+ * once at boot via `configureUiKit`, and the hooks read them.
14
+ *
15
+ * Two rules the hooks exist to enforce, because a plugin gets them wrong:
16
+ *
17
+ * - **Context arrives late.** The active session and workspace are resolved
18
+ * asynchronously, so a component that captured them once would show "none"
19
+ * over a real workspace. Every hook subscribes and re-renders.
20
+ * - **Session state is not component state.** The component is unmounted when
21
+ * its panel is hidden, so a value kept in `useState` alone would be lost on
22
+ * every tab switch. `useSessionValue` reads Kabmo's store.
23
+ *
24
+ * The surface is split across modules by what it is FOR, so a consumer imports
25
+ * the piece it needs rather than one growing file:
26
+ *
27
+ * | Module | What it holds |
28
+ * |---|---|
29
+ * | `./services` | The injected services and the prop contracts |
30
+ * | `./panels` | `usePanelInfo`, `usePanelHost`, `useSessionValue`, `useWorkspaceFile` |
31
+ * | `./fetch` | `useKabmoFetch` — a plugin's own read of Kabmo's HTTP API (poll + events) |
32
+ * | `./participant` | `useRpc`, `useRealtime`, `useComposer` — what lets a plugin ACT on the conversation |
33
+ * | `./components` | The presentation kit (`Panel`, `Field`, `Button`, …) |
34
+ */
35
+
36
+ export type {
37
+ PanelContext,
38
+ PanelHostProps,
39
+ HostMarkdownProps,
40
+ UiKitServices,
41
+ PluginRpcError,
42
+ PluginRpcOutcome,
43
+ ComposerState,
44
+ ComposerService,
45
+ RepoScope,
46
+ RepoScopeRow,
47
+ } from './services';
48
+ export { configureUiKit, uiKitServices, requireServices } from './services';
49
+
50
+ export {
51
+ usePanelInfo,
52
+ useTheme,
53
+ useSessionValue,
54
+ useSessionState,
55
+ useWorkspaceFile,
56
+ type SessionValue,
57
+ type WorkspaceFile,
58
+ } from './panels';
59
+
60
+ export { useRepoScope, type RepoScopeHandle } from './repo';
61
+ export { RepoPicker, type RepoPickerProps } from './repo-picker';
62
+
63
+ export { PanelHostProvider, usePanelHost } from './panel-host';
64
+ export { PluginIdProvider, usePluginId } from './plugin-id';
65
+
66
+ export {
67
+ useKabmoFetch,
68
+ useChamberFetch,
69
+ DEFAULT_POLL_MS,
70
+ type KabmoFetchOptions,
71
+ type KabmoFetchState,
72
+ type ChamberFetchOptions,
73
+ type ChamberFetchState,
74
+ } from './fetch';
75
+
76
+ export {
77
+ useRpc,
78
+ useRealtime,
79
+ useComposer,
80
+ type RpcState,
81
+ type PluginRealtimeState,
82
+ type ComposerHandle,
83
+ } from './participant';
84
+
85
+ export { experimental_useSidebarThreads, experimental_useSidebarThreadActions } from './sidebar';
86
+
87
+ export * from './components';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The panel body's props, scoped to the panel that is being rendered.
8
+ *
9
+ * A Preact context rather than a service singleton, and that is the whole point:
10
+ * the desktop stack keeps EVERY built-in view mounted at once (hidden with CSS,
11
+ * so a tree's expansion and a terminal's buffer survive a switch), so "the
12
+ * current panel's props" is not a process-wide fact — there are eleven of them
13
+ * live at the same time. A module-level value would hand a wiki panel the
14
+ * terminal's `rootPath` and its `onClose`.
15
+ *
16
+ * The provider is rendered by whichever layout draws the panel, so a component
17
+ * nested deep inside a plugin's own tree reads the SAME props its top-level
18
+ * component was handed rather than a second derivation of them.
19
+ */
20
+
21
+ import { createContext } from 'preact';
22
+ import { useContext } from 'preact/hooks';
23
+ import type { ComponentChildren } from 'preact';
24
+ import type { PanelHostProps } from './services';
25
+
26
+ const PanelHostContext = createContext<PanelHostProps | null>(null);
27
+
28
+ /** Render a panel's body inside its own props scope. */
29
+ export function PanelHostProvider({ value, children }: { value: PanelHostProps; children: ComponentChildren }) {
30
+ return <PanelHostContext.Provider value={value}>{children}</PanelHostContext.Provider>;
31
+ }
32
+
33
+ /**
34
+ * The props of the panel this component is rendered in.
35
+ *
36
+ * Falls back to a permissive default when there is no provider — a component
37
+ * rendered outside a panel body (the settings pane, a test) is not "disabled",
38
+ * it simply has no layout to describe.
39
+ */
40
+ export function usePanelHost(): PanelHostProps {
41
+ return (
42
+ useContext(PanelHostContext) ?? {
43
+ sessionId: null,
44
+ enabled: true,
45
+ active: true,
46
+ refreshKey: 0,
47
+ }
48
+ );
49
+ }