@hachej/boring-ui-kit 0.4.3 → 0.4.4
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 +48 -0
- package/dist/chat-outbox.d.ts +18 -0
- package/dist/chat-outbox.js +12 -0
- package/dist/mark-store.d.ts +38 -0
- package/dist/mark-store.js +84 -0
- package/dist/markdown-editor.d.ts +6 -0
- package/dist/markdown-editor.js +17 -2
- package/dist/markdown-highlight-layer.d.ts +26 -0
- package/dist/markdown-highlight-layer.js +107 -0
- package/dist/markdown-quote.d.ts +23 -0
- package/dist/markdown-quote.js +39 -0
- package/dist/native-chat.d.ts +21 -0
- package/dist/native-chat.js +111 -7
- package/dist/remote-chat.js +16 -14
- package/dist/shared-chat/error.d.ts +10 -0
- package/dist/shared-chat/error.js +13 -0
- package/dist/shared-chat/fake.d.ts +39 -0
- package/dist/shared-chat/fake.js +90 -0
- package/dist/shared-chat/index.d.ts +51 -0
- package/dist/shared-chat/index.js +255 -0
- package/dist/shared-chat/socket.d.ts +20 -0
- package/dist/shared-chat/socket.js +114 -0
- package/dist/shared-chat/source.d.ts +117 -0
- package/dist/shared-chat/source.js +6 -0
- package/package.json +15 -3
- package/r/agent-target.json +36 -0
- package/r/app-shell.json +26 -11
- package/r/board-viewer.json +5 -5
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +10 -10
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +5 -5
- package/r/highlights.json +29 -0
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +2 -2
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +2 -2
- package/r/pi-ambient.json +7 -7
- package/r/pi-app.json +15 -14
- package/r/pi-chat.json +27 -19
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +40 -0
- package/r/registry.json +330 -86
- package/r/requests-board.json +41 -0
- package/r/shared-chat.json +79 -0
- package/r/task-list-viewer.json +2 -2
- package/r/theme.json +15 -0
- package/r/version-history.json +3 -3
- package/r/viewers.json +15 -9
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
2
|
+
import { createChatOutbox } from '../chat-outbox.js';
|
|
3
|
+
import { ChannelRequestError } from './error.js';
|
|
4
|
+
export { ChannelRequestError } from './error.js';
|
|
5
|
+
export { channelAvatarStates } from './source.js';
|
|
6
|
+
/** Host-owned multi-party log, not a Pi conversation or an agent runtime. One adapter per selected conversation. */
|
|
7
|
+
export function createSharedChat(source, options) {
|
|
8
|
+
const id = options.conversationId, limit = options.limit ?? 60;
|
|
9
|
+
let snapshot = Object.freeze({ identity: { runtimeId: 'shared-chat', scopeId: id, principalId: source.me.id }, conversationId: id,
|
|
10
|
+
connection: { kind: 'idle' }, draft: { text: '', attachments: [], version: 0 }, outbox: [], history: { kind: 'idle' }, send: { kind: 'idle' }, stop: 'idle', disposed: false,
|
|
11
|
+
shared: { me: structuredClone(source.me), members: [], messages: [], files: [], replyTo: undefined, status: 'reconnecting' } });
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
const outbox = createChatOutbox();
|
|
14
|
+
const replayable = new Set();
|
|
15
|
+
let unsubscribe, opening, paging, before;
|
|
16
|
+
let revision = 0;
|
|
17
|
+
const changed = new Map();
|
|
18
|
+
const publish = (change) => {
|
|
19
|
+
if (snapshot.disposed)
|
|
20
|
+
return;
|
|
21
|
+
snapshot = Object.freeze({ ...snapshot, ...change });
|
|
22
|
+
for (const listener of [...listeners])
|
|
23
|
+
listener();
|
|
24
|
+
};
|
|
25
|
+
const active = () => { if (snapshot.disposed)
|
|
26
|
+
throw new Error('Chat controller is disposed'); };
|
|
27
|
+
const merge = (messages, prepend = false) => {
|
|
28
|
+
// New stream events always win over an overlapping history read. Replacements keep the row's position and identity.
|
|
29
|
+
const existing = snapshot.shared.messages;
|
|
30
|
+
const all = prepend ? [...messages, ...existing] : [...existing, ...messages];
|
|
31
|
+
const byId = new Map();
|
|
32
|
+
for (const message of all) {
|
|
33
|
+
if (message.conversationId !== id)
|
|
34
|
+
continue;
|
|
35
|
+
if (message.clientId)
|
|
36
|
+
for (const [key, value] of byId)
|
|
37
|
+
if (value.clientId === message.clientId && key !== message.id)
|
|
38
|
+
byId.delete(key);
|
|
39
|
+
byId.set(message.id, structuredClone(message));
|
|
40
|
+
}
|
|
41
|
+
publish({ shared: { ...snapshot.shared, messages: [...byId.values()].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)) } });
|
|
42
|
+
};
|
|
43
|
+
const acknowledge = (message) => {
|
|
44
|
+
if (message.conversationId !== id || snapshot.disposed)
|
|
45
|
+
return;
|
|
46
|
+
changed.set(message.id, ++revision);
|
|
47
|
+
merge([message]);
|
|
48
|
+
if (message.clientId) {
|
|
49
|
+
replayable.delete(message.clientId);
|
|
50
|
+
outbox.resolve(message.clientId, message);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const sendOne = (clientId, input) => {
|
|
54
|
+
void Promise.resolve().then(() => source.send({ ...input, mentions: [...input.mentions], ...(input.attachments ? { attachments: [...input.attachments] } : {}) })).then(message => {
|
|
55
|
+
if (snapshot.disposed)
|
|
56
|
+
return;
|
|
57
|
+
if (message.conversationId !== id || message.clientId !== clientId)
|
|
58
|
+
throw new Error('Shared send returned another clientId or conversation');
|
|
59
|
+
acknowledge(message);
|
|
60
|
+
}).catch(error => {
|
|
61
|
+
if (!outbox.get(clientId))
|
|
62
|
+
return;
|
|
63
|
+
replayable.delete(clientId);
|
|
64
|
+
if (error instanceof ChannelRequestError && error.refused && !error.retryable) {
|
|
65
|
+
outbox.reject(clientId, error);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// An unconfirmed result retains its exact identity. Only an explicitly retryable failure may replay.
|
|
69
|
+
const retryable = error instanceof ChannelRequestError && error.retryable;
|
|
70
|
+
if (retryable)
|
|
71
|
+
replayable.add(clientId);
|
|
72
|
+
publish({ send: { kind: 'unknown', reason: error instanceof Error ? error.message : 'Channel send is unconfirmed', retryable } });
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
const replay = () => { for (const call of outbox.values())
|
|
76
|
+
if (replayable.has(call.input.clientId))
|
|
77
|
+
sendOne(call.input.clientId, call.input); };
|
|
78
|
+
async function refresh() {
|
|
79
|
+
// Walk backward to the newest known message, not just one page: a long disconnect must not drop a gap.
|
|
80
|
+
const known = new Set(snapshot.shared.messages.map(message => message.id));
|
|
81
|
+
let cursor;
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
do {
|
|
84
|
+
const started = revision;
|
|
85
|
+
const page = await source.history(id, { limit, ...(cursor ? { before: cursor } : {}) });
|
|
86
|
+
if (snapshot.disposed)
|
|
87
|
+
return;
|
|
88
|
+
merge(page.messages.filter(message => (changed.get(message.id) ?? 0) <= started));
|
|
89
|
+
if (!known.size && cursor === undefined)
|
|
90
|
+
before = page.before;
|
|
91
|
+
if (!page.before || !known.size || page.messages.some(message => known.has(message.id)))
|
|
92
|
+
break;
|
|
93
|
+
if (seen.has(page.before))
|
|
94
|
+
throw new Error('Channel history cursor did not progress');
|
|
95
|
+
seen.add(page.before);
|
|
96
|
+
cursor = page.before;
|
|
97
|
+
} while (cursor);
|
|
98
|
+
publish({ history: { kind: 'ready', entries: [], hasMore: before !== undefined } });
|
|
99
|
+
}
|
|
100
|
+
const event = (event) => {
|
|
101
|
+
if (snapshot.disposed)
|
|
102
|
+
return;
|
|
103
|
+
revision++;
|
|
104
|
+
if (event.type === 'message')
|
|
105
|
+
acknowledge(event.message);
|
|
106
|
+
else if (event.type === 'delta' && event.conversationId === id) {
|
|
107
|
+
const message = snapshot.shared.messages.find(message => message.id === event.messageId);
|
|
108
|
+
if (message) {
|
|
109
|
+
changed.set(message.id, revision);
|
|
110
|
+
merge([{ ...message, text: (message.text ?? '') + event.text, streaming: true }]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (event.type === 'step' && event.conversationId === id && Number.isSafeInteger(event.index) && event.index >= 0) {
|
|
114
|
+
const message = snapshot.shared.messages.find(message => message.id === event.messageId);
|
|
115
|
+
if (message) {
|
|
116
|
+
const steps = [...(message.steps ?? [])];
|
|
117
|
+
steps[event.index] = { ...event.step };
|
|
118
|
+
changed.set(message.id, revision);
|
|
119
|
+
merge([{ ...message, steps }]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (event.type === 'presence') {
|
|
123
|
+
publish({ shared: { ...snapshot.shared, members: snapshot.shared.members.map(member => {
|
|
124
|
+
const update = event.members.find(update => update.id === member.id);
|
|
125
|
+
return update ? { ...member, presence: update.presence } : member;
|
|
126
|
+
}) } });
|
|
127
|
+
}
|
|
128
|
+
else if (event.type === 'members' && event.conversationId === id)
|
|
129
|
+
publish({ shared: { ...snapshot.shared, members: structuredClone(event.members) } });
|
|
130
|
+
};
|
|
131
|
+
let reconnect;
|
|
132
|
+
const status = (value) => {
|
|
133
|
+
const previous = snapshot.shared.status;
|
|
134
|
+
publish({ shared: { ...snapshot.shared, status: value }, connection: { kind: value === 'live' ? 'connected' : 'reconnecting' } });
|
|
135
|
+
if (value === 'live' && previous !== 'live' && !opening && !reconnect) {
|
|
136
|
+
reconnect = refresh().then(replay).catch(error => publish({ connection: { kind: 'error', error } })).finally(() => { reconnect = undefined; });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const controller = {
|
|
140
|
+
getSnapshot: () => snapshot,
|
|
141
|
+
subscribe: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
142
|
+
setText: (text) => { active(); publish({ draft: { ...snapshot.draft, text, version: snapshot.draft.version + 1 } }); },
|
|
143
|
+
setAttachments: (attachments) => { active(); publish({ draft: { ...snapshot.draft, attachments, version: snapshot.draft.version + 1 } }); },
|
|
144
|
+
setFiles: (files) => { active(); publish({ shared: { ...snapshot.shared, files: [...files] } }); },
|
|
145
|
+
setReplyTo: (messageId) => { active(); publish({ shared: { ...snapshot.shared, replyTo: messageId } }); },
|
|
146
|
+
clearHistory: () => { active(); },
|
|
147
|
+
loadEarlier: () => {
|
|
148
|
+
active();
|
|
149
|
+
if (paging)
|
|
150
|
+
return paging;
|
|
151
|
+
if (!before)
|
|
152
|
+
return Promise.resolve();
|
|
153
|
+
const cursor = before;
|
|
154
|
+
publish({ history: { kind: 'loading', entries: [], hasMore: true } });
|
|
155
|
+
paging = source.history(id, { before: cursor, limit }).then(page => {
|
|
156
|
+
if (page.before === cursor)
|
|
157
|
+
throw new Error('Channel history cursor did not progress');
|
|
158
|
+
before = page.before;
|
|
159
|
+
merge(page.messages, true);
|
|
160
|
+
publish({ history: { kind: 'ready', entries: [], hasMore: before !== undefined } });
|
|
161
|
+
}).catch(error => publish({ history: { kind: 'error', entries: [], hasMore: true, error } })).finally(() => { paging = undefined; });
|
|
162
|
+
return paging;
|
|
163
|
+
},
|
|
164
|
+
connect: () => {
|
|
165
|
+
active();
|
|
166
|
+
if (opening)
|
|
167
|
+
return opening;
|
|
168
|
+
if (unsubscribe)
|
|
169
|
+
return Promise.resolve();
|
|
170
|
+
publish({ connection: { kind: 'connecting' } });
|
|
171
|
+
opening = Promise.resolve().then(async () => {
|
|
172
|
+
// Subscribe first: events arriving during the initial reads cannot fall through the history gap.
|
|
173
|
+
unsubscribe = source.subscribe(id, event, status);
|
|
174
|
+
const started = revision;
|
|
175
|
+
const members = await source.members(id);
|
|
176
|
+
if (snapshot.disposed)
|
|
177
|
+
return;
|
|
178
|
+
if (started === revision || !snapshot.shared.members.length)
|
|
179
|
+
publish({ shared: { ...snapshot.shared, members: structuredClone(members) } });
|
|
180
|
+
await refresh();
|
|
181
|
+
if (!snapshot.disposed)
|
|
182
|
+
publish({ connection: { kind: snapshot.shared.status === 'live' ? 'connected' : 'reconnecting' } });
|
|
183
|
+
}).catch(error => { unsubscribe?.(); unsubscribe = undefined; publish({ connection: { kind: 'error', error } }); }).finally(() => { opening = undefined; });
|
|
184
|
+
return opening;
|
|
185
|
+
},
|
|
186
|
+
send: async (_whenBusy, input) => {
|
|
187
|
+
active();
|
|
188
|
+
const selected = input ?? snapshot.draft;
|
|
189
|
+
const files = [...snapshot.shared.files];
|
|
190
|
+
if (!selected.text.trim() && !selected.attachments?.length && !files.length)
|
|
191
|
+
return;
|
|
192
|
+
if (selected.attachments?.length)
|
|
193
|
+
throw new Error('Use shared file attachments, not native image payloads');
|
|
194
|
+
const clientId = randomUUID();
|
|
195
|
+
const mentions = snapshot.shared.members.filter(member => hasMemberMention(selected.text, member)).map(member => member.id);
|
|
196
|
+
const payload = { conversationId: id, text: selected.text, mentions, clientId, ...(snapshot.shared.replyTo ? { replyTo: snapshot.shared.replyTo } : {}), ...(files.length ? { attachments: files } : {}) };
|
|
197
|
+
const result = outbox.enqueue(clientId, payload);
|
|
198
|
+
publish({ shared: { ...snapshot.shared, files: [], replyTo: undefined }, ...(!input ? { draft: { text: '', attachments: [], version: snapshot.draft.version + 1 } } : {}), send: { kind: 'submitting' } });
|
|
199
|
+
sendOne(clientId, payload);
|
|
200
|
+
try {
|
|
201
|
+
const message = await result;
|
|
202
|
+
publish({ send: { kind: 'admitted' } });
|
|
203
|
+
return message;
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (!snapshot.disposed && error instanceof ChannelRequestError && error.refused) {
|
|
207
|
+
const current = snapshot.draft;
|
|
208
|
+
publish({ send: { kind: 'blocked', reason: error.message }, draft: { ...current, text: [input?.restore?.text ?? selected.text, current.text].filter(Boolean).join('\n'), version: current.version + 1 },
|
|
209
|
+
shared: { ...snapshot.shared, files: [...files, ...snapshot.shared.files], replyTo: snapshot.shared.replyTo ?? payload.replyTo } });
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
reconcile: async () => {
|
|
215
|
+
active();
|
|
216
|
+
await refresh();
|
|
217
|
+
for (const call of outbox.values()) {
|
|
218
|
+
const message = snapshot.shared.messages.find(message => message.clientId === call.input.clientId);
|
|
219
|
+
if (message)
|
|
220
|
+
acknowledge(message);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
retrySameRequest: async () => { active(); replay(); },
|
|
224
|
+
stop: async () => { throw new Error('Only the host controls when an agent speaks'); },
|
|
225
|
+
dispose: async () => {
|
|
226
|
+
if (snapshot.disposed)
|
|
227
|
+
return;
|
|
228
|
+
unsubscribe?.();
|
|
229
|
+
unsubscribe = undefined;
|
|
230
|
+
for (const call of outbox.values())
|
|
231
|
+
call.reject(new Error('Shared chat disposed'));
|
|
232
|
+
outbox.clear();
|
|
233
|
+
replayable.clear();
|
|
234
|
+
snapshot = Object.freeze({ ...snapshot, disposed: true, connection: { kind: 'closed' } });
|
|
235
|
+
for (const listener of [...listeners])
|
|
236
|
+
listener();
|
|
237
|
+
listeners.clear();
|
|
238
|
+
return undefined;
|
|
239
|
+
},
|
|
240
|
+
action: (card) => card.action ? source.action({ conversationId: id, requestId: card.requestId, actionId: card.action.id }) : Promise.resolve(),
|
|
241
|
+
remove: source.remove && source.canRemove ? (message) => source.canRemove?.(message) === true ? source.remove(message.id) : Promise.reject(new Error('Removal refused')) : undefined,
|
|
242
|
+
canRemove: (message) => Boolean(source.remove && source.canRemove?.(message)),
|
|
243
|
+
};
|
|
244
|
+
return controller;
|
|
245
|
+
}
|
|
246
|
+
/** The composer uses the existing quoted @ token grammar. Tokens carry stable member ids; picker labels show names. */
|
|
247
|
+
export function memberMention(member) {
|
|
248
|
+
return /[\s"\\]/.test(member.id) ? `@"${member.id.replace(/[\\"]/g, '\\$&')}"` : `@${member.id}`;
|
|
249
|
+
}
|
|
250
|
+
function hasMemberMention(text, member) {
|
|
251
|
+
const token = memberMention(member);
|
|
252
|
+
return [...text.matchAll(/(^|\s)(@"(?:[^"\\]|\\.)*"|@[^\s]+)/g)].some(match => match[2] === token);
|
|
253
|
+
}
|
|
254
|
+
export { createFakeChannelSource } from './fake.js';
|
|
255
|
+
export { createChannelSocket } from './socket.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ChannelSource } from './source.js';
|
|
2
|
+
type Routes = Pick<ChannelSource, 'conversations' | 'members' | 'history' | 'send' | 'action' | 'remove' | 'canRemove'>;
|
|
3
|
+
export interface ChannelSocketOptions {
|
|
4
|
+
/** App URL, for example `https://app.example/`. RPC lives under `_channel`; `me` supplies the live space. */
|
|
5
|
+
readonly url: string | URL;
|
|
6
|
+
readonly fetch?: (request: Request) => Promise<Response>;
|
|
7
|
+
/** Host HTTP functions override the default routes independently. */
|
|
8
|
+
readonly routes?: Partial<Routes>;
|
|
9
|
+
/** Host subprotocol authentication may be supplied here. Closing a viewer closes only its own socket. */
|
|
10
|
+
readonly open?: (url: URL) => WebSocket;
|
|
11
|
+
readonly reconnectMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface ChannelSocketSource extends ChannelSource {
|
|
14
|
+
readonly space: string;
|
|
15
|
+
readonly join: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** The platform app host's POST `_channel/<op>` and space WebSocket. Policy and identity remain at that host. */
|
|
18
|
+
export declare function createChannelSocket(options: ChannelSocketOptions): Promise<ChannelSocketSource>;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=socket.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { randomUUID } from '@hachej/boring-files/platform';
|
|
3
|
+
import { ChannelRequestError } from './error.js';
|
|
4
|
+
const member = z.object({ id: z.string(), name: z.string(), kind: z.enum(['person', 'agent', 'admin']), role: z.union([z.enum(['owner', 'validator', 'member', 'admin']), z.templateLiteral(['agent:', z.string()])]).optional(), avatarUrl: z.string().optional(), presence: z.enum(['here', 'away']).optional() });
|
|
5
|
+
const conversation = z.object({ id: z.string(), title: z.string(), kind: z.string(), members: z.array(z.string()) });
|
|
6
|
+
const step = z.object({ label: z.string(), detail: z.string().optional(), status: z.enum(['running', 'done', 'failed']) });
|
|
7
|
+
const message = z.object({ id: z.string(), conversationId: z.string(), author: z.string(), at: z.string(), via: z.string().optional(), text: z.string().optional(),
|
|
8
|
+
card: z.object({ requestId: z.string(), title: z.string(), line: z.string(), since: z.string().optional(), state: z.string().optional(), action: z.object({ id: z.string(), label: z.string() }).optional() }).optional(),
|
|
9
|
+
mentions: z.array(z.string()).optional(), replyTo: z.string().optional(), attachments: z.array(z.object({ path: z.string(), title: z.string().optional() })).optional(), steps: z.array(step).optional(), streaming: z.boolean().optional(),
|
|
10
|
+
removed: z.object({ by: z.string(), at: z.string() }).optional(), clientId: z.string().optional() });
|
|
11
|
+
const event = z.discriminatedUnion('type', [z.object({ type: z.literal('message'), message }),
|
|
12
|
+
z.object({ type: z.literal('delta'), messageId: z.string(), conversationId: z.string(), text: z.string() }),
|
|
13
|
+
z.object({ type: z.literal('step'), messageId: z.string(), conversationId: z.string(), index: z.number().int().nonnegative(), step }),
|
|
14
|
+
z.object({ type: z.literal('presence'), members: z.array(z.object({ id: z.string(), presence: z.enum(['here', 'away']) })) }),
|
|
15
|
+
z.object({ type: z.literal('members'), conversationId: z.string(), members: z.array(member) })]);
|
|
16
|
+
/** The platform app host's POST `_channel/<op>` and space WebSocket. Policy and identity remain at that host. */
|
|
17
|
+
export async function createChannelSocket(options) {
|
|
18
|
+
const base = new URL(options.url, globalThis.location?.href);
|
|
19
|
+
base.pathname = base.pathname.replace(/\/$/, '') + '/';
|
|
20
|
+
const request = options.fetch ?? (request => globalThis.fetch(request));
|
|
21
|
+
const url = (path) => new URL(path, base);
|
|
22
|
+
const failure = z.object({ error: z.object({ code: z.string(), message: z.string(), retryable: z.boolean() }) });
|
|
23
|
+
async function rpc(op, input = {}) {
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await request(new Request(url(`_channel/${op}`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new ChannelRequestError('network', error instanceof Error ? error.message : 'Channel connection was lost', true);
|
|
30
|
+
}
|
|
31
|
+
const body = await response.json().catch(() => undefined);
|
|
32
|
+
const rejected = failure.safeParse(body);
|
|
33
|
+
if (rejected.success) {
|
|
34
|
+
const error = rejected.data.error;
|
|
35
|
+
throw new ChannelRequestError(error.code, error.message, error.retryable, { refused: true });
|
|
36
|
+
}
|
|
37
|
+
if (!response.ok || body === undefined)
|
|
38
|
+
throw new ChannelRequestError('invalid-response', `Channel ${op} returned an invalid response (${response.status})`, false);
|
|
39
|
+
return body;
|
|
40
|
+
}
|
|
41
|
+
const bootstrap = z.object({ space: z.string().min(1), me: member }).parse(await rpc('me'));
|
|
42
|
+
const space = bootstrap.space, me = bootstrap.me;
|
|
43
|
+
return {
|
|
44
|
+
me, space,
|
|
45
|
+
conversations: options.routes?.conversations ?? (async () => z.array(conversation).parse(await rpc('conversations'))),
|
|
46
|
+
members: options.routes?.members ?? (async (conversationId) => z.array(member).parse(await rpc('members', { conversationId }))),
|
|
47
|
+
history: options.routes?.history ?? (async (conversationId, opts) => z.object({ messages: z.array(message), before: z.string().optional() }).parse(await rpc('history', { conversationId, ...opts }))),
|
|
48
|
+
send: options.routes?.send ?? (async (input) => {
|
|
49
|
+
if (input.attachments?.length)
|
|
50
|
+
throw new ChannelRequestError('unsupported-attachments', 'File attachments are not available here.', false, { refused: true });
|
|
51
|
+
const { conversationId, text, mentions, replyTo, clientId } = input;
|
|
52
|
+
return message.parse(await rpc('send', { conversationId, text, mentions, ...(replyTo === undefined ? {} : { replyTo }), clientId, idempotencyKey: clientId }));
|
|
53
|
+
}),
|
|
54
|
+
action: options.routes?.action ?? (async (input) => { await rpc('action', { ...input, idempotencyKey: randomUUID() }); }),
|
|
55
|
+
remove: options.routes?.remove ?? (async (messageId) => { await rpc('remove', { messageId }); }),
|
|
56
|
+
join: async () => { z.object({ space: z.string() }).parse(await rpc('join')); },
|
|
57
|
+
...(options.routes?.canRemove ? { canRemove: options.routes.canRemove } : {}),
|
|
58
|
+
subscribe: (id, onEvent, onStatus) => {
|
|
59
|
+
let disposed = false, socket, retry, attempt = 0;
|
|
60
|
+
function connect() {
|
|
61
|
+
if (disposed)
|
|
62
|
+
return;
|
|
63
|
+
onStatus?.('reconnecting');
|
|
64
|
+
const target = url(`spaces/${encodeURIComponent(space)}/live`);
|
|
65
|
+
target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
66
|
+
let current;
|
|
67
|
+
try {
|
|
68
|
+
current = (options.open ?? (url => new WebSocket(url)))(target);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
lost();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
socket = current;
|
|
75
|
+
current.onopen = () => { if (!disposed && current === socket) {
|
|
76
|
+
attempt = 0;
|
|
77
|
+
onStatus?.('live');
|
|
78
|
+
} };
|
|
79
|
+
current.onmessage = frame => {
|
|
80
|
+
if (disposed || current !== socket || typeof frame.data !== 'string')
|
|
81
|
+
return;
|
|
82
|
+
const parsed = event.safeParse((() => { try {
|
|
83
|
+
return JSON.parse(frame.data);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
} })());
|
|
88
|
+
if (parsed.success) {
|
|
89
|
+
const update = parsed.data;
|
|
90
|
+
if (update.type === 'presence' || (update.type === 'message' ? update.message.conversationId : update.conversationId) === id)
|
|
91
|
+
onEvent(update);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
current.close(1002, 'Invalid channel event');
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
current.onerror = () => current.close();
|
|
98
|
+
current.onclose = () => { if (!disposed && current === socket) {
|
|
99
|
+
socket = undefined;
|
|
100
|
+
lost();
|
|
101
|
+
} };
|
|
102
|
+
}
|
|
103
|
+
function lost() {
|
|
104
|
+
if (disposed)
|
|
105
|
+
return;
|
|
106
|
+
onStatus?.('offline');
|
|
107
|
+
clearTimeout(retry);
|
|
108
|
+
retry = setTimeout(connect, Math.min(30000, (options.reconnectMs ?? 1000) * 2 ** attempt++));
|
|
109
|
+
}
|
|
110
|
+
connect();
|
|
111
|
+
return () => { disposed = true; clearTimeout(retry); const current = socket; socket = undefined; current?.close(); };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export type Member = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
kind: 'person' | 'agent' | 'admin';
|
|
5
|
+
role?: 'owner' | 'validator' | 'member' | 'admin' | `agent:${string}`;
|
|
6
|
+
avatarUrl?: string;
|
|
7
|
+
presence?: 'here' | 'away';
|
|
8
|
+
};
|
|
9
|
+
export type Conversation = {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
kind: 'main' | 'admin' | 'direct' | string;
|
|
13
|
+
members: string[];
|
|
14
|
+
};
|
|
15
|
+
export type Card = {
|
|
16
|
+
requestId: string;
|
|
17
|
+
title: string;
|
|
18
|
+
line: string;
|
|
19
|
+
since?: string;
|
|
20
|
+
state?: string;
|
|
21
|
+
action?: {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export type Step = {
|
|
27
|
+
label: string;
|
|
28
|
+
detail?: string;
|
|
29
|
+
status: 'running' | 'done' | 'failed';
|
|
30
|
+
};
|
|
31
|
+
export type AvatarState = 'working' | 'thinking' | 'waiting' | 'blocked' | 'done' | 'neutral';
|
|
32
|
+
/** Host lifecycle ids affect presentation only. Missing and unknown ids have no indicator. */
|
|
33
|
+
export declare const channelAvatarStates: Readonly<Record<string, AvatarState | undefined>>;
|
|
34
|
+
export type ChannelMessage = {
|
|
35
|
+
id: string;
|
|
36
|
+
conversationId: string;
|
|
37
|
+
author: string;
|
|
38
|
+
at: string;
|
|
39
|
+
via?: string;
|
|
40
|
+
text?: string;
|
|
41
|
+
card?: Card;
|
|
42
|
+
mentions?: string[];
|
|
43
|
+
replyTo?: string;
|
|
44
|
+
attachments?: {
|
|
45
|
+
path: string;
|
|
46
|
+
title?: string;
|
|
47
|
+
}[];
|
|
48
|
+
steps?: Step[];
|
|
49
|
+
streaming?: boolean;
|
|
50
|
+
removed?: {
|
|
51
|
+
by: string;
|
|
52
|
+
at: string;
|
|
53
|
+
};
|
|
54
|
+
clientId?: string;
|
|
55
|
+
};
|
|
56
|
+
export type ChannelEvent = {
|
|
57
|
+
type: 'message';
|
|
58
|
+
message: ChannelMessage;
|
|
59
|
+
} | {
|
|
60
|
+
type: 'delta';
|
|
61
|
+
messageId: string;
|
|
62
|
+
conversationId: string;
|
|
63
|
+
text: string;
|
|
64
|
+
} | {
|
|
65
|
+
type: 'step';
|
|
66
|
+
messageId: string;
|
|
67
|
+
conversationId: string;
|
|
68
|
+
index: number;
|
|
69
|
+
step: Step;
|
|
70
|
+
} | {
|
|
71
|
+
type: 'presence';
|
|
72
|
+
members: {
|
|
73
|
+
id: string;
|
|
74
|
+
presence: 'here' | 'away';
|
|
75
|
+
}[];
|
|
76
|
+
} | {
|
|
77
|
+
type: 'members';
|
|
78
|
+
conversationId: string;
|
|
79
|
+
members: Member[];
|
|
80
|
+
};
|
|
81
|
+
export interface ChannelSource {
|
|
82
|
+
readonly me: Member;
|
|
83
|
+
readonly conversations: () => Promise<Conversation[]>;
|
|
84
|
+
readonly members: (conversationId: string) => Promise<Member[]>;
|
|
85
|
+
readonly history: (conversationId: string, opts: {
|
|
86
|
+
before?: string;
|
|
87
|
+
limit: number;
|
|
88
|
+
}) => Promise<{
|
|
89
|
+
messages: ChannelMessage[];
|
|
90
|
+
before?: string;
|
|
91
|
+
}>;
|
|
92
|
+
readonly send: (input: {
|
|
93
|
+
conversationId: string;
|
|
94
|
+
text: string;
|
|
95
|
+
mentions: string[];
|
|
96
|
+
replyTo?: string;
|
|
97
|
+
clientId: string;
|
|
98
|
+
attachments?: File[];
|
|
99
|
+
}) => Promise<ChannelMessage>;
|
|
100
|
+
readonly action: (input: {
|
|
101
|
+
conversationId: string;
|
|
102
|
+
requestId: string;
|
|
103
|
+
actionId: string;
|
|
104
|
+
}) => Promise<void>;
|
|
105
|
+
readonly remove?: (messageId: string) => Promise<void>;
|
|
106
|
+
readonly canRemove?: (message: ChannelMessage) => boolean;
|
|
107
|
+
readonly subscribe: (conversationId: string, onEvent: (event: ChannelEvent) => void, onStatus?: (status: 'live' | 'reconnecting' | 'offline') => void) => () => void;
|
|
108
|
+
}
|
|
109
|
+
export interface SharedChatState {
|
|
110
|
+
readonly me: Member;
|
|
111
|
+
readonly members: readonly Member[];
|
|
112
|
+
readonly messages: readonly ChannelMessage[];
|
|
113
|
+
readonly files: readonly File[];
|
|
114
|
+
readonly replyTo: string | undefined;
|
|
115
|
+
readonly status: 'live' | 'reconnecting' | 'offline';
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=source.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Host lifecycle ids affect presentation only. Missing and unknown ids have no indicator. */
|
|
2
|
+
export const channelAvatarStates = Object.freeze({
|
|
3
|
+
building: 'working', executing: 'working', checks: 'working', asked: 'thinking',
|
|
4
|
+
question: 'waiting', answer: 'waiting', review: 'waiting', preview: 'waiting',
|
|
5
|
+
platform: 'blocked', fixing: 'blocked', live: 'done', declined: 'neutral',
|
|
6
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-ui-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -55,6 +55,10 @@
|
|
|
55
55
|
"types": "./dist/native-chat.d.ts",
|
|
56
56
|
"import": "./dist/native-chat.js"
|
|
57
57
|
},
|
|
58
|
+
"./shared-chat": {
|
|
59
|
+
"types": "./dist/shared-chat/index.d.ts",
|
|
60
|
+
"import": "./dist/shared-chat/index.js"
|
|
61
|
+
},
|
|
58
62
|
"./remote-chat": {
|
|
59
63
|
"types": "./dist/remote-chat.d.ts",
|
|
60
64
|
"import": "./dist/remote-chat.js"
|
|
@@ -111,10 +115,18 @@
|
|
|
111
115
|
"types": "./dist/file-tree-view.d.ts",
|
|
112
116
|
"import": "./dist/file-tree-view.js"
|
|
113
117
|
},
|
|
114
|
-
"./file-tree.css": "./styles/file-tree.css"
|
|
118
|
+
"./file-tree.css": "./styles/file-tree.css",
|
|
119
|
+
"./markdown-quote": {
|
|
120
|
+
"types": "./dist/markdown-quote.d.ts",
|
|
121
|
+
"import": "./dist/markdown-quote.js"
|
|
122
|
+
},
|
|
123
|
+
"./mark-store": {
|
|
124
|
+
"types": "./dist/mark-store.d.ts",
|
|
125
|
+
"import": "./dist/mark-store.js"
|
|
126
|
+
}
|
|
115
127
|
},
|
|
116
128
|
"peerDependencies": {
|
|
117
|
-
"@hachej/boring-files": "^0.4.
|
|
129
|
+
"@hachej/boring-files": "^0.4.4",
|
|
118
130
|
"@earendil-works/pi-durable": "~1.2.0",
|
|
119
131
|
"react": "^19.0.0",
|
|
120
132
|
"react-dom": "^19.0.0",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "agent-target",
|
|
4
|
+
"title": "Point and dictate on app data",
|
|
5
|
+
"description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.4",
|
|
8
|
+
"@hachej/boring-feedback@^0.4.4",
|
|
9
|
+
"@earendil-works/pi-durable@~1.2.0",
|
|
10
|
+
"react@^19.0.0",
|
|
11
|
+
"react-dom@^19.0.0"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/app-shell.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/pi-chat.json",
|
|
16
|
+
"button",
|
|
17
|
+
"card",
|
|
18
|
+
"message",
|
|
19
|
+
"bubble"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
{
|
|
23
|
+
"path": "registry/agent-target/agent-target.tsx",
|
|
24
|
+
"content": "'use client';\n\n// Host presentation over the app's borrowed chat controller, not a feedback report or a second Pi lifecycle.\nimport { createPortal } from 'react-dom';\nimport { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { CSSProperties } from 'react';\nimport type { EntryRecord, JsonObject, Submission } from '@earendil-works/pi-durable';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { createPicker, createPrivacyPolicy, isExcluded } from '@hachej/boring-feedback/page';\nimport type { Picker } from '@hachej/boring-feedback/page';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { useAgentLayer } from '../app-shell/agent-layer';\nimport { ScreenActiveContext } from '../app-shell/agent-context';\nimport { useDictation } from '../app-shell/use-dictation';\nimport { useVisualViewport } from '../app-shell/use-visual-viewport';\nimport { object } from '../pi-chat/rows';\nimport { agentTargetLabels } from './agent-target-labels';\nimport type { AgentTargetLabels } from './agent-target-labels';\n\nexport interface AgentTargetResult { readonly target: JsonObject; readonly reply: string; readonly ok: boolean }\nexport interface AgentTargetModeProps {\n readonly enabled: boolean;\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n readonly onSettled: (result: AgentTargetResult) => void;\n readonly attribute?: string;\n readonly labels?: Partial<AgentTargetLabels> | undefined;\n}\ntype Stage = 'dictating' | 'sending' | 'observing' | 'settled' | 'cancelled';\ninterface Session {\n readonly target: JsonObject;\n readonly block: string;\n readonly element: Element;\n readonly controller: NativeChatController;\n readonly abort: AbortController;\n readonly onSettled: AgentTargetModeProps['onSettled'];\n readonly labels: AgentTargetLabels;\n stage: Stage;\n}\ninterface Presentation { readonly element: Element; readonly stage: Stage; readonly reply: string; readonly problem?: string }\n\nfunction freeze(value: unknown): void {\n if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }\n}\nfunction contextOf(element: Element, attribute: string, labels: AgentTargetLabels): { readonly target: JsonObject; readonly block: string } {\n const raw = element.getAttribute(attribute) ?? '';\n if (new TextEncoder().encode(raw).length > 1024) throw new Error(labels.contextTooLarge);\n let target: unknown;\n try { target = JSON.parse(raw); } catch { throw new Error(labels.invalidContext); }\n if (!target || typeof target !== 'object' || Array.isArray(target)) throw new Error(labels.invalidContext);\n const json = JSON.stringify(target).replace(/</g, '\\\\u003c');\n if (new TextEncoder().encode(json).length > 1024) throw new Error(labels.contextTooLarge);\n freeze(target);\n return { target: target as JsonObject, block: `<target>${json}</target>` };\n}\nconst textOf = (entries: readonly EntryRecord[]): string => entries.flatMap(entry => (entry.model ?? []).flatMap(message => message.role === 'assistant'\n ? message.content.flatMap(part => part.type === 'text' ? [part.text] : []) : [])).filter(Boolean).join('\\n\\n');\n\n/** A local observation only. Stream attribution uses native run inputs; settlement uses the native submission's exact answer entry. */\nfunction observe(session: Session, submission: Submission, show: (reply: string) => void, settle: (reply: string, ok: boolean, problem?: string) => void): void {\n let timer: ReturnType<typeof setTimeout> | undefined;\n const { controller, abort } = session;\n const stream = () => {\n if (abort.signal.aborted || session.stage !== 'observing') return;\n const view = controller.getSnapshot().view;\n const live = object(view?.docs['pi.live']), run = object(live?.['run']);\n if (!Array.isArray(run?.['inputs']) || !run['inputs'].includes(submission.id)) return;\n const generation = object(live?.['generation']), message = object(generation?.['message']);\n const partial = Array.isArray(message?.['content']) ? message['content'].map(object).flatMap(part => part?.['type'] === 'text' && typeof part['text'] === 'string' ? [part['text']] : []).join('\\n') : '';\n show([textOf((view?.entries ?? []).filter(entry => entry.byTaskId === run['taskId'])), partial].filter(Boolean).join('\\n\\n'));\n };\n const unsubscribe = controller.subscribe(stream);\n const stop = () => { clearTimeout(timer); unsubscribe(); abort.signal.removeEventListener('abort', stop); };\n abort.signal.addEventListener('abort', stop, { once: true });\n const poll = async () => {\n try {\n const record = await controller.submissionStatus(submission);\n if (abort.signal.aborted) return;\n stream();\n if (record.status === 'unanswered') { stop(); settle('', false, session.labels.unanswered); return; }\n if (record.status === 'done' && record.type === 'input') {\n const view = controller.getSnapshot().view;\n const answer = view?.entries.find(entry => entry.id === record.answer);\n if (answer) {\n const entries = answer.byTaskId === undefined ? [answer] : view!.entries.filter(entry => entry.byTaskId === answer.byTaskId);\n stop(); settle(textOf(entries), true); return;\n }\n // A coalesced watch may arrive after status. An unavailable answer stays unconfirmed, never guessed from neighbouring text.\n }\n if (!abort.signal.aborted) timer = setTimeout(() => { void poll(); }, 150);\n } catch {\n stop();\n if (!abort.signal.aborted) settle('', false, session.labels.observationFailed);\n }\n };\n stream();\n void poll();\n}\n\n/** Tap only explicitly marked app data, dictate once and show its native answer without opening the app's chat. */\nexport function AgentTargetMode({ enabled, transcribe, onSettled, labels, attribute = 'data-agent-target' }: AgentTargetModeProps) {\n const words = { ...agentTargetLabels, ...labels };\n const { agent } = useAgentLayer();\n const active = useContext(ScreenActiveContext);\n const viewport = useVisualViewport();\n const session = useRef<Session | null>(null);\n const picker = useRef<Picker | null>(null);\n const box = useRef<HTMLDivElement>(null);\n const latest = useRef({ onSettled, words });\n latest.current = { onSettled, words };\n const [shown, setShown] = useState<Presentation | null>(null);\n const [place, setPlace] = useState<CSSProperties>();\n const settle = (selected: Session, reply: string, ok: boolean, problem?: string) => {\n if (session.current !== selected || selected.abort.signal.aborted || selected.stage === 'settled') return;\n selected.stage = 'settled';\n setShown({ element: selected.element, stage: 'settled', reply, ...(problem ? { problem } : {}) });\n selected.onSettled({ target: selected.target, reply, ok });\n };\n const dictation = useDictation({ transcribe, labels: words, onText: async spoken => {\n const selected = session.current;\n if (!selected || selected.stage !== 'dictating') return;\n selected.stage = 'sending';\n setShown({ element: selected.element, stage: 'sending', reply: '' });\n let submission: Submission | undefined;\n try {\n await selected.controller.connect();\n if (selected.abort.signal.aborted || session.current !== selected) return;\n submission = await selected.controller.send('followUp', { text: `${selected.block}\\n\\n${spoken}`, restore: { text: spoken } });\n }\n catch {\n if (selected.abort.signal.aborted || session.current !== selected) return;\n const snapshot = selected.controller.getSnapshot();\n if (!snapshot.disposed) {\n const draft = snapshot.draft.text;\n selected.controller.setText(`${draft}${draft && !/\\s$/.test(draft) ? ' ' : ''}${spoken}`);\n }\n settle(selected, '', false, snapshot.disposed ? selected.labels.unavailable : selected.labels.sendFailed);\n return;\n }\n if (selected.abort.signal.aborted || session.current !== selected) return;\n if (!submission) { settle(selected, '', false, selected.labels.unconfirmed); return; }\n selected.stage = 'observing';\n setShown({ element: selected.element, stage: 'observing', reply: '' });\n observe(selected, submission, reply => {\n if (session.current === selected && !selected.abort.signal.aborted) setShown({ element: selected.element, stage: 'observing', reply });\n }, (reply, ok, problem) => settle(selected, reply, ok, problem));\n } });\n const { start, cancel: cancelDictation } = dictation;\n const cancel = useCallback(() => {\n const old = session.current;\n session.current = null;\n if (old) { old.stage = 'cancelled'; old.abort.abort(); }\n cancelDictation();\n picker.current?.suspend(true);\n setShown(null);\n }, [cancelDictation]);\n\n useEffect(() => {\n if (!enabled || !active || !agent) return;\n if (!/^data-[a-z][a-z0-9-]*$/.test(attribute)) throw new TypeError('Agent target attribute must be a data-* attribute name');\n const selector = `[${attribute}]`;\n let pickedGesture: Element | null = null;\n const marked = (element: Element | null) => element && !isExcluded(element) ? element.closest(selector) : null;\n const gate = (event: Event) => {\n const element = event.target instanceof Element ? event.target : null;\n if (event.type === 'pointerdown') pickedGesture = null;\n if (element && box.current?.contains(element)) return;\n if (session.current || box.current) { if (event.type === 'pointerdown') cancel(); return; }\n if (element && isExcluded(element)) { picker.current?.suspend(true); return; }\n picker.current?.suspend(!marked(element));\n };\n const key = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && (session.current || box.current || picker.current?.state().suspended === false)) { event.preventDefault(); event.stopImmediatePropagation(); cancel(); }\n };\n const click = (event: MouseEvent) => {\n // Suspending the picker on touch pointerup removes its touchend listener. Consume only that tap's compatibility click.\n if (pickedGesture && event.target instanceof Node && pickedGesture.contains(event.target)) { pickedGesture = null; event.preventDefault(); event.stopImmediatePropagation(); }\n };\n // Registered before the picker: unmarked actions remain ordinary app actions, not consumed empty picks.\n window.addEventListener('pointerdown', gate, true);\n window.addEventListener('pointermove', gate, true);\n window.addEventListener('keydown', key, true);\n window.addEventListener('click', click, true);\n const created = createPicker({ root: document.body, policy: createPrivacyPolicy(), tap: 'pick', wheel: false, keys: 'outside-ignored',\n hitTest: (x, y) => {\n const top = document.elementsFromPoint(x, y).find(element => !isExcluded(element));\n const target = marked(top ?? null);\n return target ? [target] : [];\n },\n onPick: element => {\n const target = marked(element);\n if (!target || session.current) return;\n pickedGesture = target;\n created.suspend(true);\n try {\n const context = contextOf(target, attribute, latest.current.words);\n session.current = { ...context, element: target, controller: agent, abort: new AbortController(), onSettled: latest.current.onSettled, labels: latest.current.words, stage: 'dictating' };\n setShown({ element: target, stage: 'dictating', reply: '' });\n start();\n } catch (error) { setShown({ element: target, stage: 'settled', reply: '', problem: error instanceof Error ? error.message : latest.current.words.invalidContext }); }\n },\n });\n picker.current = created;\n void created.start();\n created.suspend(true);\n return () => {\n window.removeEventListener('pointerdown', gate, true);\n window.removeEventListener('pointermove', gate, true);\n window.removeEventListener('keydown', key, true);\n window.removeEventListener('click', click, true);\n cancel();\n created.dispose();\n if (picker.current === created) picker.current = null;\n };\n }, [enabled, active, agent, attribute, start, cancel]);\n\n useEffect(() => {\n const selected = session.current;\n if (dictation.problem && selected?.stage === 'dictating') settle(selected, '', false, dictation.problem);\n }, [dictation.problem]);\n useLayoutEffect(() => {\n if (!shown || !box.current) return;\n const node = box.current;\n const measure = () => {\n const rect = shown.element.getBoundingClientRect();\n const topEdge = (viewport?.top ?? 0) + 8, bottom = (viewport ? viewport.top + viewport.height : window.innerHeight) - 8;\n const height = Math.min(node.getBoundingClientRect().height, Math.max(0, bottom - topEdge));\n const lowest = Math.max(topEdge, bottom - height);\n const top = Math.max(topEdge, Math.min(rect.bottom + 8 + height <= bottom ? rect.bottom + 8 : rect.top - height - 8 >= topEdge ? rect.top - height - 8 : lowest, lowest));\n const width = Math.min(Math.max(240, rect.width), window.innerWidth - 16);\n const next = { position: 'fixed' as const, top, left: Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8)), width, maxHeight: Math.max(0, bottom - top), overflowY: 'auto' as const };\n setPlace(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next);\n };\n measure();\n window.addEventListener('resize', measure);\n window.addEventListener('scroll', measure, true);\n const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);\n observer?.observe(node);\n return () => { window.removeEventListener('resize', measure); window.removeEventListener('scroll', measure, true); observer?.disconnect(); };\n }, [shown, viewport]);\n\n if (!enabled || !active || !shown || typeof document === 'undefined') return null;\n const phase = shown.stage === 'dictating' ? dictation.phase : shown.stage;\n return createPortal(<Card ref={box} size=\"sm\" data-feedback-ignore=\"\" data-boring=\"agent-target\" data-testid=\"agent-target-bar\" data-phase={phase}\n role=\"region\" aria-label={words.title} className=\"app-shell fixed z-50\" style={place}>\n <CardHeader><CardTitle>{words.title}</CardTitle></CardHeader>\n <CardContent>\n {shown.problem ? <p role=\"alert\" data-testid=\"agent-target-problem\">{shown.problem}</p>\n : shown.reply ? <Message align=\"start\"><MessageContent><Bubble variant=\"secondary\"><BubbleContent data-testid=\"agent-target-reply\" className=\"whitespace-pre-wrap\">{shown.reply}</BubbleContent></Bubble></MessageContent></Message>\n : <p role=\"status\">{phase === 'starting' ? words.opening : phase === 'recording' ? words.listening(dictation.seconds) : phase === 'transcribing' ? words.transcribing : phase === 'settled' ? words.answered : words.waiting}</p>}\n </CardContent>\n <CardFooter className=\"flex gap-2\">\n {shown.stage === 'dictating' && <Button data-testid=\"agent-target-stop\" className=\"min-h-11 min-w-11\" disabled={phase !== 'recording'} onClick={dictation.finish}>{words.stop}</Button>}\n <Button variant=\"ghost\" data-testid=\"agent-target-close\" className=\"min-h-11 min-w-11\" onClick={cancel}>{shown.stage === 'settled' ? words.close : words.cancel}</Button>\n </CardFooter>\n </Card>, document.body);\n}\n",
|
|
25
|
+
"type": "registry:component",
|
|
26
|
+
"target": "components/agent-target/agent-target.tsx"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"path": "registry/agent-target/agent-target-labels.ts",
|
|
30
|
+
"content": "import { chatTextFor } from '../pi-chat/locale';\nimport type { DictationLabels } from '../app-shell/use-dictation';\n\nexport interface AgentTargetLabels extends DictationLabels {\n readonly title: string;\n readonly opening: string;\n readonly listening: (seconds: number) => string;\n readonly transcribing: string;\n readonly waiting: string;\n readonly answered: string;\n readonly stop: string;\n readonly cancel: string;\n readonly close: string;\n readonly invalidContext: string;\n readonly contextTooLarge: string;\n readonly observationFailed: string;\n readonly unanswered: string;\n readonly unavailable: string;\n readonly sendFailed: string;\n readonly unconfirmed: string;\n}\n\nexport const agentTargetLabels: AgentTargetLabels = {\n ...chatTextFor(undefined).labels,\n title: 'Talk about this item',\n opening: 'Opening microphone…',\n listening: seconds => `Listening… ${seconds}s`,\n transcribing: 'Transcribing…',\n waiting: 'Waiting for the agent…',\n answered: 'Request answered.',\n stop: 'Stop and send',\n cancel: 'Cancel',\n close: 'Close',\n invalidContext: 'Target context must be a JSON object.',\n contextTooLarge: 'Target context exceeds 1 KB.',\n observationFailed: 'Could not confirm the reply. Open chat to check it.',\n unanswered: 'The agent did not answer this request.',\n unavailable: 'The chat is no longer available.',\n sendFailed: 'The request could not be sent. Your words are in the chat draft.',\n unconfirmed: 'The request was not confirmed. Check the chat before trying again.',\n};\n\nexport const agentTargetLabelsFr: AgentTargetLabels = {\n ...agentTargetLabels,\n ...chatTextFor('fr').labels,\n title: 'Parler de cet élément',\n opening: 'Ouverture du microphone…',\n listening: seconds => `J’écoute… ${seconds} s`,\n transcribing: 'Transcription…',\n waiting: 'Réponse en cours…',\n answered: 'Demande traitée.',\n stop: 'Arrêter et envoyer',\n cancel: 'Annuler',\n close: 'Fermer',\n invalidContext: 'Le contexte doit être un objet JSON.',\n contextTooLarge: 'Le contexte dépasse 1 Ko.',\n observationFailed: 'Réponse non confirmée. Ouvrez la discussion pour la vérifier.',\n unanswered: 'L’assistant n’a pas répondu à cette demande.',\n unavailable: 'La discussion n’est plus disponible.',\n sendFailed: 'Envoi impossible. Vos mots sont conservés dans le brouillon de la discussion.',\n unconfirmed: 'Envoi non confirmé. Vérifiez la discussion avant de réessayer.',\n};\n",
|
|
31
|
+
"type": "registry:lib",
|
|
32
|
+
"target": "components/agent-target/agent-target-labels.ts"
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"type": "registry:component"
|
|
36
|
+
}
|