@hachej/boring-ui-kit 0.4.24 → 0.4.26

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.
Files changed (76) hide show
  1. package/README.md +26 -2
  2. package/bin/boring-ui-blocks.mjs +50 -13
  3. package/dist/chat-artifacts.d.ts +157 -0
  4. package/dist/chat-artifacts.js +274 -0
  5. package/dist/chat-react-controller.d.ts +25 -0
  6. package/dist/chat-react-controller.js +72 -0
  7. package/dist/chat-react-conversations.d.ts +40 -0
  8. package/dist/chat-react-conversations.js +68 -0
  9. package/dist/chat-react-draft.d.ts +11 -0
  10. package/dist/chat-react-draft.js +91 -0
  11. package/dist/chat-react-remote.d.ts +33 -0
  12. package/dist/chat-react-remote.js +72 -0
  13. package/dist/chat-react-transcript.d.ts +39 -0
  14. package/dist/chat-react-transcript.js +123 -0
  15. package/dist/chat-react.d.ts +60 -0
  16. package/dist/chat-react.js +94 -0
  17. package/dist/chat-text.d.ts +122 -0
  18. package/dist/chat-text.js +216 -0
  19. package/dist/chat-view.d.ts +2 -17
  20. package/dist/chat-view.js +1 -0
  21. package/dist/file-tree.js +5 -0
  22. package/dist/run-notifications.d.ts +42 -0
  23. package/dist/run-notifications.js +67 -0
  24. package/dist/shared-chat/index.d.ts +1 -1
  25. package/dist/shared-chat/index.js +5 -4
  26. package/dist/voice-capture.d.ts +12 -0
  27. package/dist/voice-capture.js +17 -2
  28. package/package.json +10 -2
  29. package/r/agent-target.json +5 -9
  30. package/r/app-shell.json +16 -25
  31. package/r/board-viewer.json +7 -11
  32. package/r/button.json +2 -9
  33. package/r/diagram-viewer.json +0 -5
  34. package/r/dock.json +2 -2
  35. package/r/documents.json +14 -19
  36. package/r/feedback-preview.json +1 -7
  37. package/r/feedback.json +12 -19
  38. package/r/file-tree.json +4 -14
  39. package/r/highlights.json +4 -9
  40. package/r/html-viewer.json +3 -3
  41. package/r/looks-viewer.json +2 -6
  42. package/r/maquette-viewer.json +2 -20
  43. package/r/markdown-editor.json +4 -7
  44. package/r/mockup-viewer.json +2 -5
  45. package/r/pi-ambient.json +11 -19
  46. package/r/pi-app-kinds.json +10 -10
  47. package/r/pi-app.json +16 -26
  48. package/r/pi-artifacts.json +3 -4
  49. package/r/pi-chat-approval.json +3 -5
  50. package/r/pi-chat-attachments.json +2 -4
  51. package/r/pi-chat-markdown.json +3 -5
  52. package/r/pi-chat-mentions.json +2 -3
  53. package/r/pi-chat-pickers.json +2 -4
  54. package/r/pi-chat-plugins.json +11 -11
  55. package/r/pi-chat-question.json +1 -7
  56. package/r/pi-chat-slash.json +3 -6
  57. package/r/pi-chat-subagent.json +3 -8
  58. package/r/pi-chat-voice.json +6 -9
  59. package/r/pi-chat.json +17 -45
  60. package/r/pi-workspace.json +4 -7
  61. package/r/placed-agent.json +4 -5
  62. package/r/provider-setup.json +2 -8
  63. package/r/record-viewer.json +0 -9
  64. package/r/registry.json +163 -395
  65. package/r/requests-board.json +2 -10
  66. package/r/shared-chat.json +9 -23
  67. package/r/task-list-viewer.json +3 -9
  68. package/r/version-history.json +3 -5
  69. package/r/viewer-code.json +2 -3
  70. package/r/viewer-frame.json +7 -15
  71. package/r/viewer-html.json +4 -5
  72. package/r/viewer-image.json +3 -4
  73. package/r/viewer-markdown.json +6 -8
  74. package/r/viewer-pdf.json +5 -7
  75. package/r/viewers.json +6 -6
  76. package/r/windows.json +2 -7
@@ -0,0 +1,25 @@
1
+ export type ConversationControllerState<C> = {
2
+ readonly status: 'connecting';
3
+ } | {
4
+ readonly status: 'offline';
5
+ readonly error: unknown;
6
+ } | {
7
+ readonly status: 'ready';
8
+ readonly controller: C;
9
+ };
10
+ export interface ControllerPerConversationOptions<C extends {
11
+ readonly dispose: () => unknown;
12
+ }> {
13
+ /** Stable selection identity, including source/owner and relevant options. Undefined acquires nothing. */
14
+ readonly key: unknown;
15
+ readonly create: () => C | Promise<C>;
16
+ readonly connect?: ((controller: C) => Promise<void>) | undefined;
17
+ /** Defaults to the concrete controller's dispose. Must release only resources created by this binding. */
18
+ readonly dispose?: ((controller: C) => unknown) | undefined;
19
+ readonly retryMs?: number | undefined;
20
+ }
21
+ /** Owns only the selected adapter, never its borrowed Harness/source. Late acquisitions are released, not published. */
22
+ export declare function useControllerPerConversation<C extends {
23
+ readonly dispose: () => unknown;
24
+ }>(options: ControllerPerConversationOptions<C>): ConversationControllerState<C>;
25
+ //# sourceMappingURL=chat-react-controller.d.ts.map
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ /** Owns only the selected adapter, never its borrowed Harness/source. Late acquisitions are released, not published. */
4
+ export function useControllerPerConversation(options) {
5
+ const { key, retryMs = 1000 } = options;
6
+ const binding = useMemo(() => ({ key }), [key, retryMs]);
7
+ const latest = useRef(options);
8
+ useLayoutEffect(() => { latest.current = options; });
9
+ const [stored, setStored] = useState();
10
+ useEffect(() => {
11
+ if (key === undefined)
12
+ return;
13
+ // Capture ownership callbacks for this selection; later renders must not redirect its teardown.
14
+ const { create, connect, dispose } = latest.current;
15
+ let off = false, owned, retiring;
16
+ let timer, wake;
17
+ const publish = (state) => { if (!off)
18
+ setStored({ binding, state }); };
19
+ const retire = () => {
20
+ if (retiring)
21
+ return retiring;
22
+ const controller = owned;
23
+ owned = undefined;
24
+ if (!controller)
25
+ return Promise.resolve();
26
+ retiring = Promise.resolve().then(() => dispose ? dispose(controller) : controller.dispose()).then(() => { });
27
+ return retiring;
28
+ };
29
+ void (async () => {
30
+ while (!off) {
31
+ try {
32
+ owned = await create();
33
+ if (off) {
34
+ await retire();
35
+ return;
36
+ }
37
+ await connect?.(owned);
38
+ if (off) {
39
+ await retire();
40
+ return;
41
+ }
42
+ publish({ status: 'ready', controller: owned });
43
+ return;
44
+ }
45
+ catch (error) {
46
+ try {
47
+ await retire();
48
+ }
49
+ catch (teardownError) {
50
+ console.error('Chat controller teardown failed', teardownError);
51
+ }
52
+ retiring = undefined;
53
+ if (off)
54
+ return;
55
+ publish({ status: 'offline', error });
56
+ await new Promise(resolve => { wake = resolve; timer = setTimeout(resolve, retryMs); });
57
+ wake = undefined;
58
+ timer = undefined;
59
+ }
60
+ }
61
+ })();
62
+ return () => {
63
+ off = true;
64
+ if (timer !== undefined)
65
+ clearTimeout(timer);
66
+ wake?.();
67
+ void retire().catch(error => console.error('Chat controller teardown failed', error));
68
+ };
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- binding owns the selection; callback identity alone must not reconnect
70
+ }, [binding, retryMs]);
71
+ return stored?.binding === binding ? stored.state : { status: 'connecting' };
72
+ }
@@ -0,0 +1,40 @@
1
+ export interface ConversationItem {
2
+ readonly id: string;
3
+ readonly title?: string | undefined;
4
+ readonly updatedAt?: number | string | Date | undefined;
5
+ readonly lastMessage?: string | null | undefined;
6
+ readonly archived?: boolean | undefined;
7
+ }
8
+ /** Headless list/actions; row menus and other presentation extensions stay with the registry. */
9
+ export interface ConversationsClient {
10
+ readonly items: readonly ConversationItem[];
11
+ readonly activeId: string | undefined;
12
+ readonly reload: () => Promise<void>;
13
+ readonly onSelect: (id: string) => void;
14
+ readonly onNew: () => void;
15
+ readonly search: (query: string, options: {
16
+ readonly archived: boolean;
17
+ }, signal: AbortSignal) => Promise<readonly ConversationItem[]>;
18
+ readonly rename: (id: string, title: string) => Promise<void>;
19
+ readonly archive: (id: string, archived: boolean) => Promise<void>;
20
+ readonly remove: (id: string) => Promise<void>;
21
+ readonly fork: (atEntryId: string) => Promise<void>;
22
+ }
23
+ export interface ConversationsClientOptions {
24
+ readonly endpoint: string | URL | undefined;
25
+ readonly fetch: (request: Request) => Promise<Response>;
26
+ readonly activeId: string | undefined;
27
+ readonly onSelect: (id: string) => void;
28
+ readonly onChange?: (() => void | Promise<void>) | undefined;
29
+ readonly pollMs?: number;
30
+ }
31
+ /**
32
+ * The sessions list over the host's conversations handler (`createConversationsHandler` of `@hachej/boring-agent/conversations`, mounted at
33
+ * `endpoint`): the active list (polled every `pollMs` while mounted), server-side search with the Archived filter, New, rename, archive,
34
+ * delete and fork. New and fork select the conversation they made through `onSelect`. `onChange` runs after every change the person made
35
+ * (the host may reload what depends on the list). `fetch` is the host's authenticated fetch. Returns `undefined` until the first list arrives.
36
+ */
37
+ export declare function useConversationsClient({ endpoint, fetch, activeId, onSelect, onChange, pollMs }: ConversationsClientOptions): ConversationsClient | undefined;
38
+ /** Compatibility spelling retained for one release. */
39
+ export declare const useConversations: typeof useConversationsClient;
40
+ //# sourceMappingURL=chat-react-conversations.d.ts.map
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ const item = (served) => ({ id: String(served.id), title: served.title ?? undefined, updatedAt: served.updatedAt, lastMessage: served.lastMessage, archived: served.archived });
4
+ /**
5
+ * The sessions list over the host's conversations handler (`createConversationsHandler` of `@hachej/boring-agent/conversations`, mounted at
6
+ * `endpoint`): the active list (polled every `pollMs` while mounted), server-side search with the Archived filter, New, rename, archive,
7
+ * delete and fork. New and fork select the conversation they made through `onSelect`. `onChange` runs after every change the person made
8
+ * (the host may reload what depends on the list). `fetch` is the host's authenticated fetch. Returns `undefined` until the first list arrives.
9
+ */
10
+ export function useConversationsClient({ endpoint, fetch, activeId, onSelect, onChange, pollMs = 4000 }) {
11
+ const [items, setItems] = useState();
12
+ const latest = useRef({ fetch, onSelect, onChange, activeId });
13
+ useLayoutEffect(() => { latest.current = { fetch, onSelect, onChange, activeId }; });
14
+ const base = endpoint === undefined ? undefined : String(endpoint);
15
+ const call = useCallback(async (params, body, signal) => {
16
+ if (base === undefined)
17
+ throw new Error('No conversations endpoint');
18
+ const url = new URL(base, globalThis.location?.href);
19
+ for (const [key, value] of Object.entries(params))
20
+ url.searchParams.set(key, value);
21
+ const init = body === undefined ? { ...(signal ? { signal } : {}) } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), ...(signal ? { signal } : {}) };
22
+ const response = await latest.current.fetch(new Request(url, init));
23
+ if (!response.ok)
24
+ throw new Error(`Conversations: ${response.status}`);
25
+ return await response.json();
26
+ }, [base]);
27
+ const reload = useCallback(async () => { const result = await call({}); setItems((result.conversations ?? []).map(item)); }, [call]);
28
+ // Another endpoint (another owner's list) starts empty; switching conversations only reloads.
29
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- a new endpoint clears the old owner's list once, after commit
30
+ useEffect(() => { setItems(undefined); }, [base]);
31
+ useEffect(() => {
32
+ if (base === undefined)
33
+ return;
34
+ let off = false;
35
+ const load = () => call({}).then(result => { if (!off)
36
+ setItems((result.conversations ?? []).map(item)); }).catch(() => { });
37
+ void load();
38
+ const timer = setInterval(load, pollMs);
39
+ return () => { off = true; clearInterval(timer); };
40
+ }, [base, call, pollMs, activeId]);
41
+ return useMemo(() => {
42
+ if (!items)
43
+ return undefined;
44
+ const changed = async () => { await reload().catch(() => { }); await latest.current.onChange?.(); };
45
+ const op = (name, body) => call({ op: name }, body);
46
+ return {
47
+ items, activeId, reload,
48
+ onSelect: (id) => latest.current.onSelect(id),
49
+ onNew: () => { void op('create', {}).then(async (result) => { await changed(); if (result.conversationId !== undefined)
50
+ latest.current.onSelect(String(result.conversationId)); }).catch(() => { }); },
51
+ search: async (query, { archived }, signal) => ((await call({ ...(query ? { q: query } : {}), ...(archived ? { archived: '1' } : {}) }, undefined, signal)).conversations ?? []).map(item),
52
+ rename: async (id, title) => { await op('rename', { conversationId: id, title }); await changed(); },
53
+ archive: async (id, archived) => { await op('archive', { conversationId: id, archived }); await changed(); },
54
+ remove: async (id) => { await op('delete', { conversationId: id }); await changed(); },
55
+ fork: async (at) => {
56
+ const from = latest.current.activeId;
57
+ if (from === undefined)
58
+ return;
59
+ const { conversationId } = await op('fork', { conversationId: from, at });
60
+ await changed();
61
+ if (conversationId !== undefined)
62
+ latest.current.onSelect(String(conversationId));
63
+ },
64
+ };
65
+ }, [items, activeId, call, reload]);
66
+ }
67
+ /** Compatibility spelling retained for one release. */
68
+ export const useConversations = useConversationsClient;
@@ -0,0 +1,11 @@
1
+ import type { ChatController } from './native-chat.js';
2
+ /**
3
+ * Keeps the composer's half-typed message (the controller's draft) per conversation in localStorage, so it survives a reload and shows in
4
+ * every tab on the same conversation (the `storage` event). The draft is emptied when a message is sent, and a refused send puts the text back,
5
+ * which is written again. Without `key` nothing happens. Storage that is missing or throws only means the draft is not kept.
6
+ *
7
+ * On mount the conversation's saved draft replaces the composer's text; text already in the composer and not yet saved is written at once.
8
+ * On a change of `key` (same controller) the composer shows the new conversation's saved draft, or an empty one, so no text moves across.
9
+ */
10
+ export declare function useChatDraft(controller: ChatController, key: string | undefined): void;
11
+ //# sourceMappingURL=chat-react-draft.d.ts.map
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+ import { useEffect, useRef } from 'react';
3
+ const PREFIX = 'pi-chat:draft:';
4
+ /** The stored draft: a string, `null` when the key is missing, `undefined` when storage is unavailable (missing, blocked or throwing). */
5
+ function read(storageKey) {
6
+ try {
7
+ const storage = globalThis.localStorage;
8
+ return storage ? storage.getItem(storageKey) : undefined;
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ function write(storageKey, text) {
15
+ try {
16
+ if (text)
17
+ globalThis.localStorage?.setItem(storageKey, text);
18
+ else
19
+ globalThis.localStorage?.removeItem(storageKey);
20
+ }
21
+ catch { /* the composer still holds the text */ }
22
+ }
23
+ /**
24
+ * Keeps the composer's half-typed message (the controller's draft) per conversation in localStorage, so it survives a reload and shows in
25
+ * every tab on the same conversation (the `storage` event). The draft is emptied when a message is sent, and a refused send puts the text back,
26
+ * which is written again. Without `key` nothing happens. Storage that is missing or throws only means the draft is not kept.
27
+ *
28
+ * On mount the conversation's saved draft replaces the composer's text; text already in the composer and not yet saved is written at once.
29
+ * On a change of `key` (same controller) the composer shows the new conversation's saved draft, or an empty one, so no text moves across.
30
+ */
31
+ export function useChatDraft(controller, key) {
32
+ // The key the hook last loaded; a different key on a later run is a conversation switch, not a first mount.
33
+ const loaded = useRef(undefined);
34
+ useEffect(() => {
35
+ if (key === undefined || controller.getSnapshot().disposed)
36
+ return;
37
+ const storageKey = PREFIX + key;
38
+ const switched = loaded.current !== undefined && loaded.current !== key;
39
+ loaded.current = key;
40
+ const stored = read(storageKey);
41
+ const current = controller.getSnapshot().draft.text;
42
+ // The text last known to be in storage; a write happens only when the composer's text differs from it.
43
+ let saved;
44
+ if (stored) {
45
+ if (stored !== current)
46
+ controller.setText(stored);
47
+ saved = stored;
48
+ }
49
+ else if (switched) {
50
+ // The new conversation has no saved draft (or storage is unavailable): the old conversation's text must not stay.
51
+ if (current)
52
+ controller.setText('');
53
+ saved = '';
54
+ }
55
+ else {
56
+ saved = current;
57
+ if (stored === null && current)
58
+ write(storageKey, current);
59
+ }
60
+ const persist = () => {
61
+ if (controller.getSnapshot().disposed)
62
+ return;
63
+ const text = controller.getSnapshot().draft.text;
64
+ if (text === saved)
65
+ return;
66
+ saved = text;
67
+ write(storageKey, text);
68
+ };
69
+ const unsubscribe = controller.subscribe(persist);
70
+ // Another tab changed this conversation's draft (or cleared all storage: `key` is null). Adopting it does not write it back.
71
+ // Unavailable storage is not a missing draft: the composer keeps its text.
72
+ const onStorage = (event) => {
73
+ if (event.key !== null && event.key !== storageKey)
74
+ return;
75
+ if (controller.getSnapshot().disposed)
76
+ return;
77
+ const next = read(storageKey);
78
+ if (next === undefined)
79
+ return;
80
+ const text = next ?? '';
81
+ saved = text;
82
+ if (text !== controller.getSnapshot().draft.text)
83
+ controller.setText(text);
84
+ };
85
+ globalThis.addEventListener?.('storage', onStorage);
86
+ return () => {
87
+ unsubscribe();
88
+ globalThis.removeEventListener?.('storage', onStorage);
89
+ };
90
+ }, [controller, key]);
91
+ }
@@ -0,0 +1,33 @@
1
+ import type { ChatController, ChatIdentity, NativeChatController } from './native-chat.js';
2
+ import type { RemoteChat, RemoteChatOptions } from './remote-chat.js';
3
+ export type ChatPlacement = 'docked' | 'floating';
4
+ /** Reports the placement of the chat showing `controller` while the caller is mounted. */
5
+ export declare function useChatPlacement(controller: ChatController, placement: ChatPlacement): void;
6
+ export declare function readChatPlacement(controller: ChatController | undefined): ChatPlacement | undefined;
7
+ export type RemoteChatState = {
8
+ readonly status: 'connecting';
9
+ readonly conversationId: string | undefined;
10
+ } | {
11
+ readonly status: 'offline';
12
+ readonly conversationId: string | undefined;
13
+ readonly error: string;
14
+ } | {
15
+ readonly status: 'ready';
16
+ readonly conversationId: string;
17
+ readonly controller: NativeChatController;
18
+ readonly actions: Partial<Pick<RemoteChat, 'answer' | 'withdraw'>>;
19
+ readonly configure: RemoteChat['configure'];
20
+ };
21
+ export interface RemoteChatHookOptions {
22
+ readonly conversationId: string | undefined;
23
+ readonly endpoint: (conversationId: string) => string | URL;
24
+ readonly fetch: (request: Request) => Promise<Response>;
25
+ readonly identity: ChatIdentity | undefined;
26
+ /** Read at each submit; placement is merged alongside the host's unchanged screen context. Advisory, never authority. */
27
+ readonly requestContext?: () => Readonly<Record<string, unknown>> | undefined;
28
+ readonly transport?: RemoteChatOptions['transport'];
29
+ readonly webSocket?: RemoteChatOptions['webSocket'];
30
+ }
31
+ /** One owned native controller per selection; the remote transport itself reopens dropped streams. Failed acquisition retries each second. */
32
+ export declare function useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext, transport, webSocket }: RemoteChatHookOptions): RemoteChatState;
33
+ //# sourceMappingURL=chat-react-remote.d.ts.map
@@ -0,0 +1,72 @@
1
+ 'use client';
2
+ import { useLayoutEffect, useMemo, useRef } from 'react';
3
+ import { createNativeChatController } from './native-chat.js';
4
+ import { createRemoteChat } from './remote-chat.js';
5
+ import { useControllerPerConversation } from './chat-react-controller.js';
6
+ // Keyed by controller (a page can hold several chats; floating or docking keeps the controller, PLACE-3). Each mounted presenter has its own
7
+ // registration, so unmounting one never clears another's; the latest surviving registration wins.
8
+ const placements = new WeakMap();
9
+ /** Reports the placement of the chat showing `controller` while the caller is mounted. */
10
+ export function useChatPlacement(controller, placement) {
11
+ useLayoutEffect(() => {
12
+ const entry = { placement };
13
+ const list = placements.get(controller) ?? [];
14
+ placements.set(controller, [...list, entry]);
15
+ return () => {
16
+ const rest = (placements.get(controller) ?? []).filter(item => item !== entry);
17
+ if (rest.length)
18
+ placements.set(controller, rest);
19
+ else
20
+ placements.delete(controller);
21
+ };
22
+ }, [controller, placement]);
23
+ }
24
+ export function readChatPlacement(controller) {
25
+ return controller ? placements.get(controller)?.at(-1)?.placement : undefined;
26
+ }
27
+ /** One owned native controller per selection; the remote transport itself reopens dropped streams. Failed acquisition retries each second. */
28
+ export function useRemoteChat({ conversationId, endpoint, fetch, identity, requestContext, transport, webSocket }) {
29
+ const url = conversationId === undefined ? undefined : String(endpoint(conversationId));
30
+ const key = identity && conversationId !== undefined ? JSON.stringify([conversationId, url, identity.runtimeId, identity.scopeId, identity.principalId, transport]) : undefined;
31
+ const latest = useRef({ fetch, requestContext, webSocket });
32
+ useLayoutEffect(() => { latest.current = { fetch, requestContext, webSocket }; });
33
+ // Transport ownership stays with the controller that acquired it, including when connect or selection changes race.
34
+ const remotes = useMemo(() => new WeakMap(), []);
35
+ const selected = useControllerPerConversation({ key,
36
+ create: async () => {
37
+ let controller;
38
+ const remote = await createRemoteChat({ endpoint: url, fetch: request => latest.current.fetch(request),
39
+ requestContext: () => {
40
+ const placement = readChatPlacement(controller), screen = latest.current.requestContext?.();
41
+ return placement || screen ? { ...screen, ...(placement && { placement }) } : undefined;
42
+ }, ...(transport ? { transport } : {}), ...(latest.current.webSocket ? { webSocket: latest.current.webSocket } : {}) });
43
+ try {
44
+ controller = createNativeChatController({ identity: identity, ...remote });
45
+ remotes.set(controller, remote);
46
+ return controller;
47
+ }
48
+ catch (error) {
49
+ await remote.close();
50
+ throw error;
51
+ }
52
+ },
53
+ connect: controller => controller.connect(),
54
+ dispose: async (controller) => {
55
+ // Start both: closing a transport may release a pending native watch opening.
56
+ const pending = controller.dispose();
57
+ try {
58
+ await remotes.get(controller)?.close();
59
+ }
60
+ finally {
61
+ await pending;
62
+ remotes.delete(controller);
63
+ }
64
+ }, });
65
+ if (selected.status === 'ready' && conversationId !== undefined) {
66
+ const remote = remotes.get(selected.controller);
67
+ const actions = { ...(typeof remote.answer === 'function' ? { answer: remote.answer } : {}),
68
+ ...(typeof remote.withdraw === 'function' ? { withdraw: remote.withdraw } : {}) };
69
+ return { status: 'ready', conversationId, controller: selected.controller, actions, configure: remote.configure };
70
+ }
71
+ return selected.status === 'offline' ? { status: 'offline', conversationId, error: String(selected.error?.message ?? selected.error) } : { status: 'connecting', conversationId };
72
+ }
@@ -0,0 +1,39 @@
1
+ import type { ChatHistoryState } from './native-chat.js';
2
+ export interface ChatPaging {
3
+ readonly first?: number;
4
+ readonly more?: number;
5
+ }
6
+ export declare const PAGE_FIRST = 60, PAGE_MORE = 40, CHUNK = 20;
7
+ /**
8
+ * Rows grouped into runs of at most `size` keys, kept stable across renders. Rows already placed never change run: rows revealed
9
+ * above form new runs (the run nearest the old top is the full one), rows added below fill the last run, then start new ones.
10
+ */
11
+ export declare function chunkKeys(previous: readonly (readonly string[])[], keys: readonly string[], size?: number): string[][];
12
+ /**
13
+ * The scroller measures every one of its items on each scroll event and frame (@shadcn/react 0.3.1), so a long transcript gives it
14
+ * runs of `CHUNK` rows instead of single rows: 500 messages are 25 measurements, not 500. A run keeps its element while rows are
15
+ * revealed above it, so the scroller's prepend anchor still holds; a run's array is reused while its rows are unchanged.
16
+ */
17
+ export declare function useRowChunks<T extends {
18
+ readonly key: string;
19
+ }>(rows: readonly T[], size?: number): readonly (readonly T[])[];
20
+ /** One scroll root for current and archived rows. Native paging stays in the borrowed controller. */
21
+ export declare function useTranscriptWindow(rows: readonly {
22
+ readonly key: string;
23
+ }[], page?: number, more?: number, history?: {
24
+ readonly state: ChatHistoryState;
25
+ readonly connected: boolean;
26
+ readonly load: () => Promise<void>;
27
+ }, automatic?: boolean): {
28
+ scrollRef: import("react").Dispatch<import("react").SetStateAction<HTMLDivElement | null>>;
29
+ hidden: number;
30
+ reveal: () => void;
31
+ revealTo: (key: string) => void;
32
+ sentinelRef: import("react").Dispatch<import("react").SetStateAction<HTMLDivElement | null>>;
33
+ loaded: number;
34
+ visible: <T>(items: readonly T[]) => readonly T[];
35
+ reset: () => void;
36
+ };
37
+ /** Compatibility spelling for headless consumers; presentation supplies the actual scroll owner. */
38
+ export declare const useTranscript: typeof useTranscriptWindow;
39
+ //# sourceMappingURL=chat-react-transcript.d.ts.map
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+ import { startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ export const PAGE_FIRST = 60, PAGE_MORE = 40, CHUNK = 20;
4
+ /**
5
+ * Rows grouped into runs of at most `size` keys, kept stable across renders. Rows already placed never change run: rows revealed
6
+ * above form new runs (the run nearest the old top is the full one), rows added below fill the last run, then start new ones.
7
+ */
8
+ export function chunkKeys(previous, keys, size = CHUNK) {
9
+ const owner = new Map();
10
+ previous.forEach((run, index) => { for (const key of run)
11
+ owner.set(key, index); });
12
+ const known = keys.findIndex(key => owner.has(key));
13
+ const lead = known < 0 ? keys.length : known;
14
+ const runs = [];
15
+ for (let end = lead; end > 0; end -= size)
16
+ runs.unshift(keys.slice(Math.max(0, end - size), end));
17
+ let current = [], from;
18
+ for (const key of keys.slice(lead)) {
19
+ const placed = owner.get(key);
20
+ if (!current.length || (placed !== undefined ? placed !== from : current.length >= size)) {
21
+ current = [];
22
+ runs.push(current);
23
+ from = placed;
24
+ }
25
+ current.push(key);
26
+ }
27
+ return runs;
28
+ }
29
+ /**
30
+ * The scroller measures every one of its items on each scroll event and frame (@shadcn/react 0.3.1), so a long transcript gives it
31
+ * runs of `CHUNK` rows instead of single rows: 500 messages are 25 measurements, not 500. A run keeps its element while rows are
32
+ * revealed above it, so the scroller's prepend anchor still holds; a run's array is reused while its rows are unchanged.
33
+ */
34
+ export function useRowChunks(rows, size = CHUNK) {
35
+ // A derived cache of the last runs, read and replaced inside the memo only: the same rows always give the same runs.
36
+ const previous = useRef({ keys: [], runs: new Map() });
37
+ return useMemo(() => {
38
+ const byKey = new Map(rows.map(row => [row.key, row]));
39
+ // eslint-disable-next-line react-hooks/refs -- reads the run cache to keep run boundaries and arrays stable; no setState, no render loop
40
+ const keys = chunkKeys(previous.current.keys, rows.map(row => row.key), size);
41
+ // eslint-disable-next-line react-hooks/refs -- the mapper reuses the cached array of an unchanged run (see above)
42
+ const runs = keys.map(run => {
43
+ const items = run.map(key => byKey.get(key));
44
+ const before = previous.current.runs.get(run[0]);
45
+ return before && before.length === items.length && before.every((item, index) => item === items[index]) ? before : items;
46
+ });
47
+ // eslint-disable-next-line react-hooks/refs -- stores the runs for the next rows; a cache write, not state, so it triggers no render
48
+ previous.current = { keys, runs: new Map(runs.map(run => [run[0].key, run])) };
49
+ return runs;
50
+ }, [rows, size]);
51
+ }
52
+ /** One scroll root for current and archived rows. Native paging stays in the borrowed controller. */
53
+ export function useTranscriptWindow(rows, page = PAGE_FIRST, more = PAGE_MORE, history, automatic = true) {
54
+ if (!Number.isSafeInteger(page) || page < 1)
55
+ throw new TypeError('Chat displayLimit must be a positive integer');
56
+ if (!Number.isSafeInteger(more) || more < 1)
57
+ throw new TypeError('Chat paging.more must be a positive integer');
58
+ const [limit, setLimit] = useState(page);
59
+ const [sentinel, setSentinel] = useState(null);
60
+ const [loaded, setLoaded] = useState(0);
61
+ const pending = useRef(false);
62
+ const anchor = useRef(undefined);
63
+ const [scroller, setScroller] = useState(null);
64
+ const hidden = Math.max(0, rows.length - limit);
65
+ const historyState = history?.state, historyConnected = history?.connected, historyLoad = history?.load;
66
+ const reveal = useCallback(() => {
67
+ if (pending.current || anchor.current)
68
+ return;
69
+ const state = historyState;
70
+ if (hidden === 0 && (!historyConnected || !state || state.kind === 'disabled' || state.kind === 'loading' || state.kind === 'ready' && !state.hasMore))
71
+ return;
72
+ anchor.current = { length: rows.length, limit };
73
+ // A revealed page renders as a transition: React mounts it in small slices, so scrolling stays responsive meanwhile.
74
+ if (hidden > 0)
75
+ startTransition(() => setLimit(value => value + more));
76
+ else if (historyLoad) {
77
+ pending.current = true;
78
+ void Promise.resolve().then(historyLoad).catch(() => { anchor.current = undefined; }).finally(() => { pending.current = false; });
79
+ }
80
+ }, [hidden, historyState, historyConnected, historyLoad, rows.length, limit, more]);
81
+ useLayoutEffect(() => {
82
+ const saved = anchor.current;
83
+ if (!scroller || !saved || history?.state.kind === 'loading')
84
+ return;
85
+ const added = Math.max(0, rows.length - saved.length);
86
+ if (limit < saved.limit + added) {
87
+ startTransition(() => setLimit(saved.limit + added));
88
+ return;
89
+ }
90
+ anchor.current = undefined;
91
+ setLoaded(count => count + Math.max(0, Math.min(rows.length, limit) - Math.min(saved.length, saved.limit)));
92
+ }, [rows, limit, scroller, history?.state]);
93
+ // Automatic paging: the sentinel near the top, once the reader has been away from the top (so the mount, which starts at the
94
+ // top before the scroller settles at the end, loads nothing). A fast scroll lands on 0 before the sentinel is seen: the scroll
95
+ // listener and every new observer (after each page) check again, instead of the reader's distance at that one moment.
96
+ const near = useRef(false), away = useRef(false), revealNow = useRef(reveal);
97
+ useLayoutEffect(() => { revealNow.current = reveal; });
98
+ useEffect(() => {
99
+ if (!automatic || !scroller)
100
+ return;
101
+ const onScroll = () => { if (scroller.scrollTop > 8)
102
+ away.current = true; if (near.current && away.current)
103
+ revealNow.current(); };
104
+ scroller.addEventListener('scroll', onScroll, { passive: true });
105
+ return () => scroller.removeEventListener('scroll', onScroll);
106
+ }, [automatic, scroller]);
107
+ useEffect(() => {
108
+ if (!automatic || !scroller || !sentinel || typeof IntersectionObserver === 'undefined' || history?.state.kind === 'loading' || history?.state.kind === 'error')
109
+ return;
110
+ const observer = new IntersectionObserver(entries => {
111
+ near.current = entries.at(-1)?.isIntersecting ?? near.current;
112
+ if (near.current && (scroller.scrollHeight <= scroller.clientHeight || away.current))
113
+ reveal();
114
+ }, { root: scroller, rootMargin: '240px 0px 0px' });
115
+ observer.observe(sentinel);
116
+ return () => { observer.disconnect(); near.current = false; };
117
+ }, [automatic, scroller, sentinel, reveal, history?.state]);
118
+ const revealTo = (key) => { const index = rows.findIndex(row => row.key === key); if (index >= 0)
119
+ setLimit(value => Math.max(value, rows.length - index)); };
120
+ return { scrollRef: setScroller, hidden, reveal, revealTo, sentinelRef: setSentinel, loaded, visible: (items) => hidden ? items.slice(hidden) : items, reset: () => { anchor.current = undefined; setLimit(page); } };
121
+ }
122
+ /** Compatibility spelling for headless consumers; presentation supplies the actual scroll owner. */
123
+ export const useTranscript = useTranscriptWindow;
@@ -0,0 +1,60 @@
1
+ import type { ChatController } from './native-chat.js';
2
+ import type { ArtifactDescriptor, ArtifactDetect, PresentAcksHost } from './chat-view.js';
3
+ export { useChatDraft } from './chat-react-draft.js';
4
+ export { useControllerPerConversation } from './chat-react-controller.js';
5
+ export type { ControllerPerConversationOptions, ConversationControllerState } from './chat-react-controller.js';
6
+ export { chunkKeys, useRowChunks, useTranscript, useTranscriptWindow, PAGE_FIRST, PAGE_MORE, CHUNK } from './chat-react-transcript.js';
7
+ export type { ChatPaging } from './chat-react-transcript.js';
8
+ export { useChatPlacement, readChatPlacement, useRemoteChat } from './chat-react-remote.js';
9
+ export type { ChatPlacement, RemoteChatState, RemoteChatHookOptions } from './chat-react-remote.js';
10
+ export { useConversationsClient, useConversations } from './chat-react-conversations.js';
11
+ export type { ConversationItem, ConversationsClient, ConversationsClientOptions } from './chat-react-conversations.js';
12
+ import type { NotificationStore, RunNotificationOptions, RunNotificationSource } from './run-notifications.js';
13
+ export { createNotificationStore, watchConversation } from './run-notifications.js';
14
+ export type { AgentNotification, NewNotification, NotificationKind, NotificationStore, RunNotificationOptions, RunNotificationSource } from './run-notifications.js';
15
+ /** The foreground adapter of watchConversation, with the same connected-only transition rules and borrowed lifecycle. */
16
+ export declare function useRunNotifications(controller: RunNotificationSource, store: NotificationStore, options: RunNotificationOptions): void;
17
+ /** Selects the authorized native view using the controller's own cached snapshot and subscription. Borrows its lifetime. */
18
+ export declare function useChatView<T>(controller: ChatController | undefined, select: (view: ReturnType<ChatController['getSnapshot']>['view']) => T): T;
19
+ export interface ArtifactSelection {
20
+ readonly descriptor: ArtifactDescriptor;
21
+ readonly follow: boolean;
22
+ }
23
+ export interface ArtifactShown {
24
+ readonly path: string;
25
+ readonly revision: string;
26
+ readonly hidden?: true;
27
+ }
28
+ export interface ArtifactFailed {
29
+ readonly path: string;
30
+ readonly revision: string;
31
+ readonly reason: string;
32
+ }
33
+ export interface ArtifactFollowOptions {
34
+ readonly controller: ChatController | undefined;
35
+ readonly conversationId?: string | undefined;
36
+ readonly detect?: ArtifactDetect | undefined;
37
+ readonly active: ArtifactSelection | null;
38
+ readonly onOpen: (selection: ArtifactSelection) => void;
39
+ readonly autoOpen?: boolean | undefined;
40
+ /** Host presentation guards are read when an effect or action considers opening, never during render. */
41
+ readonly unsaved?: (() => boolean) | undefined;
42
+ readonly narrow?: (() => boolean) | undefined;
43
+ readonly showing: (path: string) => boolean;
44
+ readonly openPresent: (path: string) => boolean;
45
+ readonly answer?: PresentAcksHost['answer'] | undefined;
46
+ }
47
+ /** One version/follow/auto-open and present-ack rule for every chat surface. Mount evidence must come from the viewer. */
48
+ export declare function useArtifactFollow(options: ArtifactFollowOptions): {
49
+ versions: ArtifactDescriptor[];
50
+ turn: number;
51
+ known: ArtifactDescriptor[];
52
+ shown: ArtifactDescriptor | undefined;
53
+ open: (descriptor: ArtifactDescriptor) => void;
54
+ isOpen: (descriptor: ArtifactDescriptor) => boolean;
55
+ select: (version: ArtifactDescriptor | "latest") => void;
56
+ recordClosed: () => void;
57
+ onShown: (shown: ArtifactShown) => void;
58
+ onFailed: (failed: ArtifactFailed) => void;
59
+ };
60
+ //# sourceMappingURL=chat-react.d.ts.map