@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/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
+ }