@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 +38 -4
- package/src/components.tsx +163 -0
- package/src/fetch.test.tsx +167 -0
- package/src/fetch.ts +173 -0
- package/src/index.tsx +87 -0
- package/src/panel-host.tsx +49 -0
- package/src/panels.ts +206 -0
- package/src/participant.test.tsx +238 -0
- package/src/participant.ts +186 -0
- package/src/plugin-id.tsx +79 -0
- package/src/repo-picker.tsx +212 -0
- package/src/repo.test.tsx +218 -0
- package/src/repo.ts +72 -0
- package/src/services.ts +309 -0
- package/src/session-value.test.tsx +162 -0
- package/src/sidebar.test.tsx +190 -0
- package/src/sidebar.ts +136 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kabmo-labs/kabmo-ui",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|