@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/src/panels.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Panel-facing hooks: what a view is rendered with, and how it reads the
|
|
8
|
+
* Kabmo's own state.
|
|
9
|
+
*
|
|
10
|
+
* These are the pieces a built-in panel and a plugin panel both need, and the
|
|
11
|
+
* reason they live in the published kit is that the alternative — each panel
|
|
12
|
+
* re-deriving them — is how Kabmo's eleven views drifted apart in the
|
|
13
|
+
* first place.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
17
|
+
import { requireServices, type PanelContext } from './services';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The live panel context, re-rendering on every change.
|
|
21
|
+
*
|
|
22
|
+
* `useState` is seeded from the service and updated by its subscription, which
|
|
23
|
+
* is what makes a workspace that arrives after the first paint show up.
|
|
24
|
+
*/
|
|
25
|
+
export function usePanelInfo(): PanelContext {
|
|
26
|
+
const [context, setContext] = useState<PanelContext>(() => requireServices().context());
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const api = requireServices();
|
|
30
|
+
setContext(api.context());
|
|
31
|
+
return api.subscribe(setContext);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return context;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The live palette id. */
|
|
38
|
+
export function useTheme(): string {
|
|
39
|
+
return usePanelInfo().theme;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SessionValue {
|
|
43
|
+
/** `null` until the read answers — the field should be disabled, not empty. */
|
|
44
|
+
value: string | null;
|
|
45
|
+
status: 'loading' | 'idle' | 'saving' | 'saved' | 'error';
|
|
46
|
+
error: string | null;
|
|
47
|
+
update: (next: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-session state, as a `useState` that persists.
|
|
52
|
+
*
|
|
53
|
+
* The write is debounced: `onInput` fires per keystroke, and each one would
|
|
54
|
+
* otherwise be a store write and a request.
|
|
55
|
+
*/
|
|
56
|
+
export function useSessionValue(key: string, delayMs = 400): SessionValue {
|
|
57
|
+
const { sessionId } = usePanelInfo();
|
|
58
|
+
const [value, setValue] = useState<string | null>(null);
|
|
59
|
+
const [status, setStatus] = useState<SessionValue['status']>('loading');
|
|
60
|
+
const [error, setError] = useState<string | null>(null);
|
|
61
|
+
// Distinguishes "this component's own edit" from "someone else wrote the
|
|
62
|
+
// slot". Only the former may set `saving`, or an unrelated write would mark a
|
|
63
|
+
// field dirty and write it straight back.
|
|
64
|
+
const editing = useRef(false);
|
|
65
|
+
|
|
66
|
+
const read = useCallback((): string => {
|
|
67
|
+
try {
|
|
68
|
+
const stored = requireServices().getSessionValue(sessionId, key) ?? '';
|
|
69
|
+
setError(null);
|
|
70
|
+
return stored;
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
73
|
+
setStatus('error');
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
}, [sessionId, key]);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
editing.current = false;
|
|
80
|
+
setStatus('loading');
|
|
81
|
+
setValue(read());
|
|
82
|
+
setStatus((current) => (current === 'error' ? current : 'idle'));
|
|
83
|
+
}, [read]);
|
|
84
|
+
|
|
85
|
+
// Follow writes from anywhere else — the header readout showing a note's
|
|
86
|
+
// length, a second view of the same value. Skipped while THIS component is
|
|
87
|
+
// mid-edit, because its own state is the newer truth until the debounce lands.
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const api = requireServices();
|
|
90
|
+
if (!api.subscribeSessionValue) return;
|
|
91
|
+
return api.subscribeSessionValue(sessionId, key, () => {
|
|
92
|
+
if (editing.current) return;
|
|
93
|
+
setValue(read());
|
|
94
|
+
});
|
|
95
|
+
}, [sessionId, key, read]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (value === null || status !== 'saving') return;
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
try {
|
|
101
|
+
requireServices().setSessionValue(sessionId, key, value);
|
|
102
|
+
editing.current = false;
|
|
103
|
+
setStatus('saved');
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
106
|
+
setStatus('error');
|
|
107
|
+
}
|
|
108
|
+
}, delayMs);
|
|
109
|
+
return () => clearTimeout(timer);
|
|
110
|
+
}, [sessionId, key, value, status, delayMs]);
|
|
111
|
+
|
|
112
|
+
const update = useCallback((next: string) => {
|
|
113
|
+
editing.current = true;
|
|
114
|
+
setValue(next);
|
|
115
|
+
setStatus('saving');
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
return { value, status, error, update };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface WorkspaceFile {
|
|
122
|
+
content: string | null;
|
|
123
|
+
loading: boolean;
|
|
124
|
+
error: string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Per-session state of any JSON shape, persisted by Kabmo's store.
|
|
129
|
+
*
|
|
130
|
+
* The `useState`-with-persistence contract, for a value that is not a string: a
|
|
131
|
+
* collapsed-phase map, a picked file. `ready` is false until the session's blob
|
|
132
|
+
* has loaded, so a view can hold its first render rather than paint a fallback
|
|
133
|
+
* over a value that is on its way.
|
|
134
|
+
*/
|
|
135
|
+
export function useSessionState<T>(
|
|
136
|
+
key: string,
|
|
137
|
+
fallback: T,
|
|
138
|
+
): [T, (value: T | ((prev: T) => T)) => void, boolean] {
|
|
139
|
+
const { sessionId } = usePanelInfo();
|
|
140
|
+
const [ready, setReady] = useState(false);
|
|
141
|
+
const [local, setLocal] = useState<T>(() => requireServices().getSessionJson?.<T>(sessionId, key) ?? fallback);
|
|
142
|
+
|
|
143
|
+
const read = useCallback(() => {
|
|
144
|
+
const stored = requireServices().getSessionJson?.<T>(sessionId, key);
|
|
145
|
+
setLocal(stored === undefined ? fallback : stored);
|
|
146
|
+
}, [sessionId, key, fallback]);
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
setReady(true);
|
|
150
|
+
read();
|
|
151
|
+
}, [read]);
|
|
152
|
+
|
|
153
|
+
// Follow writes from anywhere else, the same reason `useSessionValue` does:
|
|
154
|
+
// two readers of one key must not hold two private copies of the same fact.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const api = requireServices();
|
|
157
|
+
if (!api.subscribeSessionJson) return;
|
|
158
|
+
return api.subscribeSessionJson(sessionId, key, read);
|
|
159
|
+
}, [sessionId, key, read]);
|
|
160
|
+
|
|
161
|
+
const setValue = useCallback(
|
|
162
|
+
(value: T | ((prev: T) => T)) => {
|
|
163
|
+
const api = requireServices();
|
|
164
|
+
const current = api.getSessionJson?.<T>(sessionId, key) ?? fallback;
|
|
165
|
+
const resolved = typeof value === 'function' ? (value as (prev: T) => T)(current) : value;
|
|
166
|
+
setLocal(resolved);
|
|
167
|
+
api.setSessionJson?.(sessionId, key, resolved);
|
|
168
|
+
},
|
|
169
|
+
[sessionId, key, fallback],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return [local, setValue, ready];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Read one text file inside the active workspace. */
|
|
176
|
+
export function useWorkspaceFile(relativePath: string): WorkspaceFile {
|
|
177
|
+
const { workspacePath } = usePanelInfo();
|
|
178
|
+
const [content, setContent] = useState<string | null>(null);
|
|
179
|
+
const [loading, setLoading] = useState(true);
|
|
180
|
+
const [error, setError] = useState<string | null>(null);
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
let cancelled = false;
|
|
184
|
+
setLoading(true);
|
|
185
|
+
setError(null);
|
|
186
|
+
requireServices()
|
|
187
|
+
.readWorkspaceFile(workspacePath, relativePath)
|
|
188
|
+
.then(
|
|
189
|
+
(text) => {
|
|
190
|
+
if (cancelled) return;
|
|
191
|
+
setContent(text);
|
|
192
|
+
setLoading(false);
|
|
193
|
+
},
|
|
194
|
+
(cause: unknown) => {
|
|
195
|
+
if (cancelled) return;
|
|
196
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
197
|
+
setLoading(false);
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
return () => {
|
|
201
|
+
cancelled = true;
|
|
202
|
+
};
|
|
203
|
+
}, [workspacePath, relativePath]);
|
|
204
|
+
|
|
205
|
+
return { content, loading, error };
|
|
206
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The participant hooks: `useRpc`, `useRealtime`, `useComposer`.
|
|
8
|
+
*
|
|
9
|
+
* Each one is a behaviour a plugin would otherwise hand-roll, and each of those
|
|
10
|
+
* behaviours is a bug when it is missing:
|
|
11
|
+
*
|
|
12
|
+
* - `useRpc` must return a plugin's REFUSAL as a result rather than throwing.
|
|
13
|
+
* The route answers `{ ok: false, error }` at HTTP 200, because the call
|
|
14
|
+
* reached the plugin and the plugin said no — flattening that into a rejected
|
|
15
|
+
* promise turns "the plugin refused this argument" into "the network broke".
|
|
16
|
+
* - `useRpc` must drop a superseded call's answer, or a slow first call repaints
|
|
17
|
+
* the panel a faster second one already moved.
|
|
18
|
+
* - `useRealtime` must seed from the value on hand before subscribing: a
|
|
19
|
+
* component that mounted between two pushes would otherwise render blank.
|
|
20
|
+
* - `useComposer` must WRITE through the host's state. A DOM write is reverted
|
|
21
|
+
* by the next keystroke, so the plugin's edit vanishes with no error.
|
|
22
|
+
*
|
|
23
|
+
* Rendered with `h()` against happy-dom, driven by the harness's own fake kit —
|
|
24
|
+
* the same seam a plugin's tests use.
|
|
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, PluginIdProvider, useComposer, useRealtime, useRpc } from '@kabmo-labs/kabmo-ui';
|
|
32
|
+
import { makeUiKitServices, type FakeUiKit } from '@kabmo-labs/kabmo-sdk/testing';
|
|
33
|
+
|
|
34
|
+
const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event'] as const;
|
|
35
|
+
const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
|
|
36
|
+
let window_: Window | null = null;
|
|
37
|
+
|
|
38
|
+
function installDom(): void {
|
|
39
|
+
window_ = new Window();
|
|
40
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
41
|
+
const source = window_ as unknown as Record<string, unknown>;
|
|
42
|
+
for (const key of DOM_GLOBALS) {
|
|
43
|
+
if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
|
|
44
|
+
target[key] = source[key];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function restoreDom(): void {
|
|
49
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
50
|
+
for (const key of DOM_GLOBALS) {
|
|
51
|
+
const value = nativeGlobals[key];
|
|
52
|
+
if (value === undefined) delete target[key];
|
|
53
|
+
else target[key] = value;
|
|
54
|
+
}
|
|
55
|
+
window_ = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let host: FakeUiKit;
|
|
59
|
+
let container: HTMLDivElement;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
installDom();
|
|
63
|
+
host = makeUiKitServices();
|
|
64
|
+
configureUiKit(host);
|
|
65
|
+
container = document.createElement('div');
|
|
66
|
+
document.body.appendChild(container);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
render(null, container);
|
|
71
|
+
container.remove();
|
|
72
|
+
restoreDom();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Render one hook INSIDE a plugin's scope, which is what a host surface does —
|
|
77
|
+
* the id the participant hooks address comes from that context, so a test that
|
|
78
|
+
* rendered outside it would only exercise the no-plugin path.
|
|
79
|
+
*/
|
|
80
|
+
function mount<T>(use: () => T, pluginId = 'demo'): { get: () => T } {
|
|
81
|
+
let latest: T;
|
|
82
|
+
function Probe() {
|
|
83
|
+
latest = use();
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
act(() => render(h(PluginIdProvider, { pluginId }, h(Probe, {})), container));
|
|
87
|
+
return { get: () => latest! };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('useRpc', () => {
|
|
91
|
+
test('a successful call answers the value and clears the error', async () => {
|
|
92
|
+
host.rpc.stub('list', [1, 2]);
|
|
93
|
+
const rpc = mount(() => useRpc<{ q: string }, number[]>('list'));
|
|
94
|
+
|
|
95
|
+
let outcome: unknown;
|
|
96
|
+
await act(async () => {
|
|
97
|
+
outcome = await rpc.get().call({ q: 'today' });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(outcome).toEqual({ ok: true, value: [1, 2] });
|
|
101
|
+
expect(rpc.get().value).toEqual([1, 2]);
|
|
102
|
+
expect(rpc.get().error).toBeNull();
|
|
103
|
+
expect(rpc.get().pending).toBe(false);
|
|
104
|
+
expect(host.rpc.calls).toEqual([{ pluginId: 'demo', method: 'list', input: { q: 'today' } }]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a plugin refusal is a RESULT, not a thrown error', async () => {
|
|
108
|
+
host.rpc.stubError('save', { code: 'invalid_input', message: 'note must be a string' });
|
|
109
|
+
const rpc = mount(() => useRpc('save'));
|
|
110
|
+
|
|
111
|
+
let outcome: unknown;
|
|
112
|
+
await act(async () => {
|
|
113
|
+
outcome = await rpc.get().call({ note: 42 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// The call did not throw, and the plugin's own reason survived.
|
|
117
|
+
expect(outcome).toEqual({ ok: false, error: { code: 'invalid_input', message: 'note must be a string' } });
|
|
118
|
+
expect(rpc.get().error).toEqual({ code: 'invalid_input', message: 'note must be a string' });
|
|
119
|
+
expect(rpc.get().value).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('an unstubbed method is unknown_method, exactly as the route answers', async () => {
|
|
123
|
+
const rpc = mount(() => useRpc('typo'));
|
|
124
|
+
let outcome: { ok: boolean } = { ok: true };
|
|
125
|
+
await act(async () => {
|
|
126
|
+
outcome = (await rpc.get().call(null)) as { ok: boolean };
|
|
127
|
+
});
|
|
128
|
+
expect(outcome.ok).toBe(false);
|
|
129
|
+
expect(rpc.get().error?.code).toBe('unknown_method');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a superseded call does not overwrite the newer one', async () => {
|
|
133
|
+
// The first call answers LATE; the second, immediately.
|
|
134
|
+
let releaseFirst: (() => void) | null = null;
|
|
135
|
+
const first = new Promise<void>((resolve) => {
|
|
136
|
+
releaseFirst = resolve;
|
|
137
|
+
});
|
|
138
|
+
let call = 0;
|
|
139
|
+
configureUiKit({
|
|
140
|
+
...host,
|
|
141
|
+
callRpc: async () => {
|
|
142
|
+
call += 1;
|
|
143
|
+
if (call === 1) {
|
|
144
|
+
await first;
|
|
145
|
+
return { ok: true, value: 'stale' };
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, value: 'fresh' };
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const rpc = mount(() => useRpc('list'));
|
|
152
|
+
await act(async () => {
|
|
153
|
+
void rpc.get().call(null);
|
|
154
|
+
await rpc.get().call(null);
|
|
155
|
+
});
|
|
156
|
+
expect(rpc.get().value).toBe('fresh');
|
|
157
|
+
|
|
158
|
+
await act(async () => {
|
|
159
|
+
releaseFirst?.();
|
|
160
|
+
await first;
|
|
161
|
+
});
|
|
162
|
+
// The late answer for the superseded call was dropped.
|
|
163
|
+
expect(rpc.get().value).toBe('fresh');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('useRealtime', () => {
|
|
168
|
+
test('seeds from the value on hand, then follows pushes', () => {
|
|
169
|
+
host.publishTopic({ count: 1 });
|
|
170
|
+
const realtime = mount(() => useRealtime<{ count: number }>());
|
|
171
|
+
|
|
172
|
+
// Seeded without waiting for the next push.
|
|
173
|
+
expect(realtime.get().value).toEqual({ count: 1 });
|
|
174
|
+
expect(realtime.get().isLoading).toBe(false);
|
|
175
|
+
|
|
176
|
+
act(() => host.publishTopic({ count: 2 }));
|
|
177
|
+
expect(realtime.get().value).toEqual({ count: 2 });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('reports stale rather than hiding it', () => {
|
|
181
|
+
const realtime = mount(() => useRealtime());
|
|
182
|
+
expect(realtime.get().stale).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('a refresh re-announces the value on hand', () => {
|
|
186
|
+
host.publishTopic('a');
|
|
187
|
+
const realtime = mount(() => useRealtime<string>());
|
|
188
|
+
act(() => realtime.get().refresh());
|
|
189
|
+
expect(realtime.get().value).toBe('a');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a subscriber that arrives before any value renders loading, not blank data', () => {
|
|
193
|
+
const realtime = mount(() => useRealtime());
|
|
194
|
+
expect(realtime.get().value).toBeNull();
|
|
195
|
+
expect(realtime.get().isLoading).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('useComposer', () => {
|
|
200
|
+
test('reads the live draft and re-renders on a change', () => {
|
|
201
|
+
host.composerControl.setState({ text: 'hello', isEmpty: false });
|
|
202
|
+
const composer = mount(() => useComposer());
|
|
203
|
+
expect(composer.get().text).toBe('hello');
|
|
204
|
+
expect(composer.get().isEmpty).toBe(false);
|
|
205
|
+
|
|
206
|
+
act(() => host.composerControl.setState({ text: 'hello world', isEmpty: false }));
|
|
207
|
+
expect(composer.get().text).toBe('hello world');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('set writes THROUGH the host, so the draft the user sees changes', () => {
|
|
211
|
+
const composer = mount(() => useComposer());
|
|
212
|
+
act(() => composer.get().set('from the plugin'));
|
|
213
|
+
expect(host.composerControl.sets).toEqual(['from the plugin']);
|
|
214
|
+
expect(host.composerControl.state.text).toBe('from the plugin');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('insert appends and focus is delegated', () => {
|
|
218
|
+
host.composerControl.setState({ text: 'a', isEmpty: false });
|
|
219
|
+
const composer = mount(() => useComposer());
|
|
220
|
+
act(() => {
|
|
221
|
+
composer.get().insert('b');
|
|
222
|
+
composer.get().focus();
|
|
223
|
+
});
|
|
224
|
+
expect(host.composerControl.inserts).toEqual(['b']);
|
|
225
|
+
expect(host.composerControl.state.text).toBe('ab');
|
|
226
|
+
expect(host.composerControl.focusCount()).toBe(1);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('reports the run state and the scope', () => {
|
|
230
|
+
host.composerControl.setState({
|
|
231
|
+
isSubmitting: true,
|
|
232
|
+
scope: { kind: 'session', sessionId: 's1' },
|
|
233
|
+
});
|
|
234
|
+
const composer = mount(() => useComposer());
|
|
235
|
+
expect(composer.get().isSubmitting).toBe(true);
|
|
236
|
+
expect(composer.get().scope).toEqual({ kind: 'session', sessionId: 's1' });
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The participant hooks: what lets a plugin ACT on the conversation.
|
|
8
|
+
*
|
|
9
|
+
* `usePanelInfo` and friends make a plugin a view — it draws into a slot and
|
|
10
|
+
* reads per-session state. These three make it a participant, and each reads
|
|
11
|
+
* the host's own machinery rather than a second copy of it:
|
|
12
|
+
*
|
|
13
|
+
* - `useRpc` calls the plugin's OWN server method, through the route the server
|
|
14
|
+
* half already answers on.
|
|
15
|
+
* - `useRealtime` subscribes to the plugin's own `plugin:<id>` topic, on the
|
|
16
|
+
* same hub every built-in panel reads.
|
|
17
|
+
* - `useComposer` reads and writes the composer's draft.
|
|
18
|
+
*
|
|
19
|
+
* Every one of them goes through `requireServices()`, so a plugin outside the
|
|
20
|
+
* app throws naming the real problem instead of failing obscurely, and the test
|
|
21
|
+
* harness answers them with fakes — a plugin's own tests never need a host.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
25
|
+
import { requireServices, type ComposerState, type PluginRpcError, type PluginRpcOutcome } from './services';
|
|
26
|
+
import { usePluginId } from './plugin-id';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Call one of this plugin's server methods.
|
|
30
|
+
*
|
|
31
|
+
* The plugin id comes from the HOST, never from the caller: a plugin that built
|
|
32
|
+
* its own URL would call another plugin's method the day the two disagreed
|
|
33
|
+
* about the id, and the host is the one party that cannot be wrong about which
|
|
34
|
+
* plugin it is running.
|
|
35
|
+
*
|
|
36
|
+
* Three behaviours, each of which is a bug if missed:
|
|
37
|
+
*
|
|
38
|
+
* - **A plugin's refusal is a RESULT, not a thrown error.** The route answers
|
|
39
|
+
* `{ ok: false, error }` at HTTP 200, because the call reached the plugin and
|
|
40
|
+
* the plugin said no. Flattening that into a rejected promise would turn "the
|
|
41
|
+
* plugin refused this argument" into "the network broke", which is the one
|
|
42
|
+
* distinction the route exists to preserve.
|
|
43
|
+
* - **A superseded call is dropped.** Two calls in flight must not leave the
|
|
44
|
+
* first one's value on screen, so each call carries a sequence number and a
|
|
45
|
+
* late answer for an older one is discarded.
|
|
46
|
+
* - **`pending` is per-hook, not per-call.** A panel wants one spinner, not one
|
|
47
|
+
* per in-flight request.
|
|
48
|
+
*/
|
|
49
|
+
export interface RpcState<In, Out> {
|
|
50
|
+
/** Invoke the method. Resolves with the outcome; never throws for a refusal. */
|
|
51
|
+
call: (input: In) => Promise<PluginRpcOutcome>;
|
|
52
|
+
/** The last successful value, kept while a newer call is in flight. */
|
|
53
|
+
value: Out | null;
|
|
54
|
+
/** The last refusal, or null. Cleared by a successful call. */
|
|
55
|
+
error: PluginRpcError | null;
|
|
56
|
+
/** A call is in flight. */
|
|
57
|
+
pending: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useRpc<In = unknown, Out = unknown>(method: string): RpcState<In, Out> {
|
|
61
|
+
const [value, setValue] = useState<Out | null>(null);
|
|
62
|
+
const [error, setError] = useState<PluginRpcError | null>(null);
|
|
63
|
+
const [pending, setPending] = useState(false);
|
|
64
|
+
// The plugin this component belongs to, from the host's context — which is
|
|
65
|
+
// what makes `method` the only thing a plugin names.
|
|
66
|
+
const pluginId = usePluginId();
|
|
67
|
+
// The newest call's sequence. An answer for anything older is dropped, so a
|
|
68
|
+
// slow first call cannot repaint the panel a faster second one already moved.
|
|
69
|
+
const sequence = useRef(0);
|
|
70
|
+
|
|
71
|
+
const call = useCallback(
|
|
72
|
+
async (input: In): Promise<PluginRpcOutcome> => {
|
|
73
|
+
const services = requireServices();
|
|
74
|
+
const mine = ++sequence.current;
|
|
75
|
+
setPending(true);
|
|
76
|
+
const outcome: PluginRpcOutcome =
|
|
77
|
+
services.callRpc && pluginId
|
|
78
|
+
? await services.callRpc(pluginId, method, input)
|
|
79
|
+
: { ok: false, error: { code: 'no_plugin', message: 'This component is not inside a plugin.' } };
|
|
80
|
+
if (mine !== sequence.current) return outcome;
|
|
81
|
+
setPending(false);
|
|
82
|
+
if (outcome.ok) {
|
|
83
|
+
setValue(outcome.value as Out);
|
|
84
|
+
setError(null);
|
|
85
|
+
} else {
|
|
86
|
+
setError(outcome.error);
|
|
87
|
+
}
|
|
88
|
+
return outcome;
|
|
89
|
+
},
|
|
90
|
+
[method, pluginId],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return { call, value, error, pending };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The plugin's own realtime topic, as a value that re-renders on each push.
|
|
98
|
+
*
|
|
99
|
+
* `stale` is surfaced rather than hidden: it means the socket is down, and a
|
|
100
|
+
* panel showing a value of unknown age as if it were current is worse than one
|
|
101
|
+
* that says so.
|
|
102
|
+
*/
|
|
103
|
+
export interface PluginRealtimeState<T> {
|
|
104
|
+
value: T | null;
|
|
105
|
+
/** No value has arrived yet. */
|
|
106
|
+
isLoading: boolean;
|
|
107
|
+
/** The value on hand is not known to be current. */
|
|
108
|
+
stale: boolean;
|
|
109
|
+
/** Ask the server for a fresh snapshot. */
|
|
110
|
+
refresh: () => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function useRealtime<T = unknown>(): PluginRealtimeState<T> {
|
|
114
|
+
const [value, setValue] = useState<T | null>(null);
|
|
115
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
116
|
+
const [stale, setStale] = useState(false);
|
|
117
|
+
const pluginId = usePluginId();
|
|
118
|
+
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const services = requireServices();
|
|
121
|
+
if (!pluginId || !services.subscribePluginTopic) return;
|
|
122
|
+
// Seed from the host's current value BEFORE subscribing: a component that
|
|
123
|
+
// mounted between two pushes would otherwise render empty until the next
|
|
124
|
+
// one, which for a slow-publishing plugin is a visible blank.
|
|
125
|
+
const seeded = services.readPluginTopic?.(pluginId);
|
|
126
|
+
if (seeded !== undefined) {
|
|
127
|
+
setValue(seeded as T);
|
|
128
|
+
setIsLoading(false);
|
|
129
|
+
}
|
|
130
|
+
setStale(services.pluginTopicStale?.(pluginId) ?? false);
|
|
131
|
+
const unsubscribe = services.subscribePluginTopic(pluginId, (next) => {
|
|
132
|
+
setValue(next as T);
|
|
133
|
+
setIsLoading(false);
|
|
134
|
+
setStale(services.pluginTopicStale?.(pluginId) ?? false);
|
|
135
|
+
});
|
|
136
|
+
return unsubscribe;
|
|
137
|
+
}, [pluginId]);
|
|
138
|
+
|
|
139
|
+
const refresh = useCallback(() => {
|
|
140
|
+
if (pluginId) requireServices().refreshPluginTopic?.(pluginId);
|
|
141
|
+
}, [pluginId]);
|
|
142
|
+
|
|
143
|
+
return { value, isLoading, stale, refresh };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The composer: its live state, and its writes.
|
|
148
|
+
*
|
|
149
|
+
* A write goes through the host's own composer state, NOT the DOM. The composer
|
|
150
|
+
* is a controlled textarea owned by the chat, so a DOM write is reverted by the
|
|
151
|
+
* next keystroke and the plugin's edit vanishes with no error — the failure
|
|
152
|
+
* this hook exists to prevent.
|
|
153
|
+
*
|
|
154
|
+
* The read is reactive and the write is immediate, which is what makes
|
|
155
|
+
* `insert('@')` a one-step operation rather than an await.
|
|
156
|
+
*/
|
|
157
|
+
export interface ComposerHandle extends ComposerState {
|
|
158
|
+
set(text: string): void;
|
|
159
|
+
insert(text: string): void;
|
|
160
|
+
focus(): void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function useComposer(): ComposerHandle {
|
|
164
|
+
const [state, setState] = useState<ComposerState>(() => {
|
|
165
|
+
const composer = requireServices().composer?.();
|
|
166
|
+
return composer ?? { text: '', isEmpty: true, isSubmitting: false, scope: { kind: 'new', sessionId: null } };
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
const services = requireServices();
|
|
171
|
+
if (!services.subscribeComposer) return;
|
|
172
|
+
return services.subscribeComposer(setState);
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
const set = useCallback((text: string) => {
|
|
176
|
+
requireServices().composer?.().set(text);
|
|
177
|
+
}, []);
|
|
178
|
+
const insert = useCallback((text: string) => {
|
|
179
|
+
requireServices().composer?.().insert(text);
|
|
180
|
+
}, []);
|
|
181
|
+
const focus = useCallback(() => {
|
|
182
|
+
requireServices().composer?.().focus();
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
return { ...state, set, insert, focus };
|
|
186
|
+
}
|