@hachej/boring-ui-kit 0.4.25 → 0.4.27
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/README.md +40 -3
- package/dist/chat-artifacts.d.ts +157 -0
- package/dist/chat-artifacts.js +274 -0
- package/dist/chat-dictation.d.ts +27 -0
- package/dist/chat-dictation.js +71 -0
- package/dist/chat-react-controller.d.ts +25 -0
- package/dist/chat-react-controller.js +72 -0
- package/dist/chat-react-conversations.d.ts +40 -0
- package/dist/chat-react-conversations.js +68 -0
- package/dist/chat-react-draft.d.ts +11 -0
- package/dist/chat-react-draft.js +91 -0
- package/dist/chat-react-remote.d.ts +33 -0
- package/dist/chat-react-remote.js +72 -0
- package/dist/chat-react-session.d.ts +170 -0
- package/dist/chat-react-session.js +367 -0
- package/dist/chat-react-transcript.d.ts +39 -0
- package/dist/chat-react-transcript.js +123 -0
- package/dist/chat-react.d.ts +62 -0
- package/dist/chat-react.js +95 -0
- package/dist/chat-text.d.ts +131 -0
- package/dist/chat-text.js +220 -0
- package/dist/chat-view.d.ts +2 -17
- package/dist/chat-view.js +1 -0
- package/dist/chat-voice-message.d.ts +65 -0
- package/dist/chat-voice-message.js +235 -0
- package/dist/chat-voice.d.ts +7 -0
- package/dist/chat-voice.js +4 -0
- package/dist/experience-catalog.d.ts +2 -2
- package/dist/experience-region-tree.d.ts +1 -1
- package/dist/markdown-editor-labels.d.ts +83 -0
- package/dist/markdown-editor-labels.js +75 -0
- package/dist/markdown-editor.d.ts +7 -1
- package/dist/markdown-editor.js +28 -18
- package/dist/markdown-extensions.d.ts +4 -2
- package/dist/markdown-extensions.js +2 -2
- package/dist/markdown-preserved.d.ts +5 -1
- package/dist/markdown-preserved.js +15 -4
- package/dist/markdown-proposal-diff.d.ts +3 -1
- package/dist/markdown-proposal-diff.js +6 -5
- package/dist/run-notifications.d.ts +42 -0
- package/dist/run-notifications.js +67 -0
- package/dist/shared-chat/index.d.ts +1 -1
- package/dist/shared-chat/index.js +5 -4
- package/dist/text-draft-controls.d.ts +5 -1
- package/dist/text-draft-controls.js +6 -5
- package/dist/text-draft-labels.d.ts +26 -0
- package/dist/text-draft-labels.js +47 -0
- package/dist/voice-attempt.d.ts +33 -0
- package/dist/voice-attempt.js +122 -0
- package/dist/voice-capture.d.ts +12 -0
- package/dist/voice-capture.js +17 -2
- package/package.json +14 -2
- package/r/agent-target.json +5 -9
- package/r/app-shell.json +19 -28
- package/r/board-viewer.json +8 -11
- package/r/button.json +2 -9
- package/r/diagram-viewer.json +0 -5
- package/r/dock.json +2 -2
- package/r/documents.json +13 -18
- package/r/feedback-preview.json +1 -7
- package/r/feedback.json +12 -19
- package/r/file-tree.json +4 -14
- package/r/highlights.json +4 -9
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +3 -6
- package/r/maquette-viewer.json +2 -20
- package/r/markdown-editor.json +4 -7
- package/r/mockup-viewer.json +2 -5
- package/r/pi-ambient.json +11 -19
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -27
- package/r/pi-artifacts.json +3 -4
- package/r/pi-chat-approval.json +2 -5
- package/r/pi-chat-attachments.json +2 -4
- package/r/pi-chat-markdown.json +3 -5
- package/r/pi-chat-mentions.json +2 -3
- package/r/pi-chat-pickers.json +2 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -7
- package/r/pi-chat-slash.json +3 -6
- package/r/pi-chat-subagent.json +3 -8
- package/r/pi-chat-voice.json +7 -10
- package/r/pi-chat.json +25 -53
- package/r/pi-workspace.json +4 -7
- package/r/placed-agent.json +4 -5
- package/r/provider-setup.json +2 -8
- package/r/record-viewer.json +0 -9
- package/r/registry.json +182 -403
- package/r/requests-board.json +2 -10
- package/r/shared-chat.json +10 -24
- package/r/task-list-viewer.json +3 -9
- package/r/utils.json +14 -2
- package/r/version-history.json +3 -5
- package/r/viewer-code.json +2 -3
- package/r/viewer-frame.json +9 -17
- package/r/viewer-html.json +4 -5
- package/r/viewer-image.json +3 -4
- package/r/viewer-markdown.json +6 -8
- package/r/viewer-pdf.json +5 -7
- package/r/viewers.json +6 -6
- package/r/windows.json +3 -8
|
@@ -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,170 @@
|
|
|
1
|
+
import type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ChatAttachment, ChatController } from './native-chat.js';
|
|
3
|
+
import type { ChatCard, DeriveOptions, Mode, QueuedMessage, Row } from './chat-view.js';
|
|
4
|
+
/** A conversation's observed file; opening remains a host operation, not workspace ownership. */
|
|
5
|
+
export interface ConversationFile {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly open: (() => void) | undefined;
|
|
9
|
+
}
|
|
10
|
+
export interface ModelRef {
|
|
11
|
+
readonly provider: string;
|
|
12
|
+
readonly modelId: string;
|
|
13
|
+
}
|
|
14
|
+
export interface PastedText {
|
|
15
|
+
readonly text: string;
|
|
16
|
+
readonly start: number;
|
|
17
|
+
readonly end: number;
|
|
18
|
+
}
|
|
19
|
+
export interface UploadResult {
|
|
20
|
+
readonly path?: string;
|
|
21
|
+
readonly image?: {
|
|
22
|
+
readonly data: string;
|
|
23
|
+
readonly mimeType: string;
|
|
24
|
+
};
|
|
25
|
+
readonly name: string;
|
|
26
|
+
}
|
|
27
|
+
export interface PendingUpload {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly state: 'uploading' | 'failed';
|
|
31
|
+
readonly error?: string;
|
|
32
|
+
readonly cancel?: (() => void) | undefined;
|
|
33
|
+
readonly restoreWhile?: string | undefined;
|
|
34
|
+
}
|
|
35
|
+
export type FilesHandler = (files: readonly File[], target: Pick<ReturnType<ChatController['getSnapshot']>, 'identity' | 'conversationId'> & {
|
|
36
|
+
readonly signal: AbortSignal;
|
|
37
|
+
}) => Promise<readonly ChatAttachment[]>;
|
|
38
|
+
export interface QueueActions {
|
|
39
|
+
readonly edit?: ((item: QueuedMessage) => Promise<void>) | undefined;
|
|
40
|
+
readonly steer?: ((item: QueuedMessage) => Promise<void>) | undefined;
|
|
41
|
+
}
|
|
42
|
+
/** Send-time feedback only; the annotate button and its state remain host presentation. */
|
|
43
|
+
export interface ChatSessionFeedback {
|
|
44
|
+
readonly count: number;
|
|
45
|
+
readonly attach: (text: string) => Promise<{
|
|
46
|
+
readonly kind: 'ok';
|
|
47
|
+
readonly text: string;
|
|
48
|
+
} | {
|
|
49
|
+
readonly kind: 'refused';
|
|
50
|
+
readonly reason: string;
|
|
51
|
+
}>;
|
|
52
|
+
readonly sent: () => void;
|
|
53
|
+
}
|
|
54
|
+
export interface ChatSessionLabels {
|
|
55
|
+
readonly connectionFailed: string;
|
|
56
|
+
readonly actionFailedShort: string;
|
|
57
|
+
readonly imageAttachment: string;
|
|
58
|
+
readonly queueLocked: string;
|
|
59
|
+
readonly tooLate: string;
|
|
60
|
+
readonly waitToSteer: string;
|
|
61
|
+
readonly steerRefused: string;
|
|
62
|
+
readonly steerFailed: string;
|
|
63
|
+
readonly uploadFailedShort: string;
|
|
64
|
+
readonly pastedFileName: (stamp: string) => string;
|
|
65
|
+
readonly changeRefused: string;
|
|
66
|
+
}
|
|
67
|
+
export interface ChatSessionOptions<Content = unknown> extends Partial<DeriveOptions<Content>> {
|
|
68
|
+
readonly controller: ChatController;
|
|
69
|
+
/** Surfaces remount on controller replacement. Late effects/actions also check this live binding. */
|
|
70
|
+
readonly activeController: {
|
|
71
|
+
readonly current: ChatController;
|
|
72
|
+
};
|
|
73
|
+
readonly mode: Mode;
|
|
74
|
+
readonly labels: ChatSessionLabels;
|
|
75
|
+
readonly actions?: {
|
|
76
|
+
readonly withdraw?: ((id: QueuedMessage['id']) => Promise<unknown>) | undefined;
|
|
77
|
+
} | undefined;
|
|
78
|
+
readonly toolRenderers?: Readonly<Record<string, (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard<Content> | undefined>> | undefined;
|
|
79
|
+
readonly artifacts?: (NonNullable<DeriveOptions<Content>['artifacts']> & {
|
|
80
|
+
readonly open?: ((artifact: never) => void) | undefined;
|
|
81
|
+
}) | undefined;
|
|
82
|
+
readonly mentions?: {
|
|
83
|
+
readonly open?: ((path: string) => void) | undefined;
|
|
84
|
+
readonly kinds?: Readonly<Record<string, unknown>> | undefined;
|
|
85
|
+
} | undefined;
|
|
86
|
+
readonly slash?: {
|
|
87
|
+
readonly skills?: readonly {
|
|
88
|
+
readonly name: string;
|
|
89
|
+
}[] | undefined;
|
|
90
|
+
} | undefined;
|
|
91
|
+
readonly onOpenFile?: ((path: string) => void) | undefined;
|
|
92
|
+
readonly onOpenImage?: ((image: ChatAttachment['content']) => void) | undefined;
|
|
93
|
+
readonly onFiles?: FilesHandler | undefined;
|
|
94
|
+
readonly onPickFiles?: ((files: readonly File[]) => void) | undefined;
|
|
95
|
+
readonly attachments?: {
|
|
96
|
+
readonly upload: (files: File[], signal: AbortSignal) => Promise<readonly UploadResult[]>;
|
|
97
|
+
} | undefined;
|
|
98
|
+
readonly model?: {
|
|
99
|
+
readonly change: (model: ModelRef) => Promise<void>;
|
|
100
|
+
} | undefined;
|
|
101
|
+
readonly effort?: {
|
|
102
|
+
readonly change: (level: string) => Promise<void>;
|
|
103
|
+
} | undefined;
|
|
104
|
+
readonly commandMentions?: {
|
|
105
|
+
readonly commands: readonly {
|
|
106
|
+
readonly name: string;
|
|
107
|
+
readonly behavior: 'execute' | 'insert';
|
|
108
|
+
}[];
|
|
109
|
+
readonly onActivate: (command: {
|
|
110
|
+
readonly name: string;
|
|
111
|
+
readonly behavior: 'execute' | 'insert';
|
|
112
|
+
}) => void;
|
|
113
|
+
} | undefined;
|
|
114
|
+
readonly feedback?: ChatSessionFeedback | undefined;
|
|
115
|
+
readonly afterSend?: (() => void) | undefined;
|
|
116
|
+
/** Focus is presentation; called only by actions, never during render. */
|
|
117
|
+
readonly onFocus?: (() => void) | undefined;
|
|
118
|
+
}
|
|
119
|
+
/** Headless session over the public native controller snapshot/commands. Borrows it; cleanup only aborts this hook's uploads. */
|
|
120
|
+
export declare function useChatSession<Content = unknown>(options: ChatSessionOptions<Content>): {
|
|
121
|
+
state: import("./native-chat.js").ChatSnapshot;
|
|
122
|
+
derived: import("./chat-view.js").Derived<Content>;
|
|
123
|
+
rows: Row<Content>[];
|
|
124
|
+
queued: {
|
|
125
|
+
id: import("@earendil-works/pi-durable").SubmissionId;
|
|
126
|
+
mode: "steer" | "followUp";
|
|
127
|
+
content: import("@earendil-works/chord").JsonRepresentation<import("@earendil-works/pi-durable").UserInput>;
|
|
128
|
+
entry?: import("@earendil-works/chord").JsonRepresentation<import("@earendil-works/pi-durable").InputEntry>;
|
|
129
|
+
}[];
|
|
130
|
+
queueActions: QueueActions | undefined;
|
|
131
|
+
working: boolean;
|
|
132
|
+
waitingForAnswer: boolean;
|
|
133
|
+
connected: boolean;
|
|
134
|
+
sendBlocked: boolean;
|
|
135
|
+
error: string | null;
|
|
136
|
+
setError: import("react").Dispatch<import("react").SetStateAction<string | null>>;
|
|
137
|
+
report: (cause: unknown) => void;
|
|
138
|
+
act: (action: () => Promise<unknown>) => void;
|
|
139
|
+
current: () => boolean;
|
|
140
|
+
files: ConversationFile[];
|
|
141
|
+
empty: boolean;
|
|
142
|
+
loading: boolean;
|
|
143
|
+
developer: boolean;
|
|
144
|
+
natural: boolean;
|
|
145
|
+
send: () => void;
|
|
146
|
+
putBack: (text: string, images: readonly ChatAttachment[]) => void;
|
|
147
|
+
upload: (files: readonly File[], paste?: {
|
|
148
|
+
readonly restore: () => void;
|
|
149
|
+
readonly after: string;
|
|
150
|
+
}) => Promise<void>;
|
|
151
|
+
pasteText: (paste: PastedText) => void;
|
|
152
|
+
picked: readonly string[];
|
|
153
|
+
pending: readonly PendingUpload[];
|
|
154
|
+
uploading: number;
|
|
155
|
+
activateMention: (command: {
|
|
156
|
+
readonly name: string;
|
|
157
|
+
readonly behavior: "execute" | "insert";
|
|
158
|
+
}) => void;
|
|
159
|
+
pickMention: (path: string) => void;
|
|
160
|
+
dismissUpload: (id: string) => void;
|
|
161
|
+
currentModel: ModelRef | undefined;
|
|
162
|
+
currentEffort: string | undefined;
|
|
163
|
+
changing: "model" | "effort" | null;
|
|
164
|
+
changeError: string | null;
|
|
165
|
+
dismissChangeError: () => void;
|
|
166
|
+
changeModel: (next: ModelRef) => Promise<void>;
|
|
167
|
+
changeEffort: (next: string) => Promise<void>;
|
|
168
|
+
};
|
|
169
|
+
export type ChatSession<Content = unknown> = ReturnType<typeof useChatSession<Content>>;
|
|
170
|
+
//# sourceMappingURL=chat-react-session.d.ts.map
|