cortena-ui 1.4.2 → 1.5.0

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 (133) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +149 -1
  3. package/dist/a2ui/views.js +2 -2
  4. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  5. package/dist/agent-chat/a2ui-block.js +69 -0
  6. package/dist/agent-chat/a2ui-block.js.map +1 -0
  7. package/dist/agent-chat/agui-client.d.ts +40 -0
  8. package/dist/agent-chat/agui-client.js +251 -0
  9. package/dist/agent-chat/agui-client.js.map +1 -0
  10. package/dist/agent-chat/bridge.d.ts +109 -0
  11. package/dist/agent-chat/bridge.js +349 -0
  12. package/dist/agent-chat/bridge.js.map +1 -0
  13. package/dist/agent-chat/session.d.ts +58 -0
  14. package/dist/agent-chat/session.js +249 -0
  15. package/dist/agent-chat/session.js.map +1 -0
  16. package/dist/agent-chat/store.d.ts +67 -0
  17. package/dist/agent-chat/store.js +548 -0
  18. package/dist/agent-chat/store.js.map +1 -0
  19. package/dist/agent-chat/types.d.ts +187 -0
  20. package/dist/agent-chat/types.js +17 -0
  21. package/dist/agent-chat/types.js.map +1 -0
  22. package/dist/agent-chat.d.ts +10 -0
  23. package/dist/agent-chat.js +10 -0
  24. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  25. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  26. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  27. package/dist/components/admin-permissions/context.d.ts +70 -0
  28. package/dist/components/admin-permissions/context.js +258 -0
  29. package/dist/components/admin-permissions/context.js.map +1 -0
  30. package/dist/components/admin-permissions/index.d.ts +10 -0
  31. package/dist/components/admin-permissions/licence.d.ts +15 -0
  32. package/dist/components/admin-permissions/licence.js +78 -0
  33. package/dist/components/admin-permissions/licence.js.map +1 -0
  34. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  35. package/dist/components/admin-permissions/matrix.js +191 -0
  36. package/dist/components/admin-permissions/matrix.js.map +1 -0
  37. package/dist/components/admin-permissions/members.d.ts +18 -0
  38. package/dist/components/admin-permissions/members.js +185 -0
  39. package/dist/components/admin-permissions/members.js.map +1 -0
  40. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  41. package/dist/components/admin-permissions/role-assignment.js +174 -0
  42. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  43. package/dist/components/admin-permissions/roles.d.ts +25 -0
  44. package/dist/components/admin-permissions/roles.js +168 -0
  45. package/dist/components/admin-permissions/roles.js.map +1 -0
  46. package/dist/components/admin-permissions/types.d.ts +152 -0
  47. package/dist/components/admin-permissions/types.js +63 -0
  48. package/dist/components/admin-permissions/types.js.map +1 -0
  49. package/dist/components/agent-chat-popup.d.ts +29 -0
  50. package/dist/components/agent-chat-popup.js +188 -0
  51. package/dist/components/agent-chat-popup.js.map +1 -0
  52. package/dist/components/agent-chat.d.ts +143 -0
  53. package/dist/components/agent-chat.js +578 -0
  54. package/dist/components/agent-chat.js.map +1 -0
  55. package/dist/components/app-shell.d.ts +126 -0
  56. package/dist/components/app-shell.js +297 -0
  57. package/dist/components/app-shell.js.map +1 -0
  58. package/dist/components/badge.d.ts +1 -1
  59. package/dist/components/button-link.js +1 -1
  60. package/dist/components/button.d.ts +2 -2
  61. package/dist/components/checkbox.d.ts +1 -1
  62. package/dist/components/combobox.d.ts +1 -1
  63. package/dist/components/combobox.js +1 -1
  64. package/dist/components/consent-screen.d.ts +65 -0
  65. package/dist/components/consent-screen.js +123 -0
  66. package/dist/components/consent-screen.js.map +1 -0
  67. package/dist/components/data-table/data-table.d.ts +15 -1
  68. package/dist/components/data-table/data-table.js +18 -4
  69. package/dist/components/data-table/data-table.js.map +1 -1
  70. package/dist/components/data-table/index.d.ts +4 -4
  71. package/dist/components/data-table/parts.d.ts +27 -3
  72. package/dist/components/data-table/parts.js +175 -55
  73. package/dist/components/data-table/parts.js.map +1 -1
  74. package/dist/components/data-table/types.d.ts +61 -0
  75. package/dist/components/data-table/use-data-table.js +91 -6
  76. package/dist/components/data-table/use-data-table.js.map +1 -1
  77. package/dist/components/data-table/use-server-source.js +119 -28
  78. package/dist/components/data-table/use-server-source.js.map +1 -1
  79. package/dist/components/help-panel.d.ts +131 -0
  80. package/dist/components/help-panel.js +545 -0
  81. package/dist/components/help-panel.js.map +1 -0
  82. package/dist/components/login-screen.d.ts +127 -0
  83. package/dist/components/login-screen.js +339 -0
  84. package/dist/components/login-screen.js.map +1 -0
  85. package/dist/components/session-guard.d.ts +268 -0
  86. package/dist/components/session-guard.js +632 -0
  87. package/dist/components/session-guard.js.map +1 -0
  88. package/dist/components/toast.d.ts +1 -1
  89. package/dist/core.d.ts +5 -1
  90. package/dist/core.js +11 -7
  91. package/dist/data-table.d.ts +13 -4
  92. package/dist/data-table.js +10 -2
  93. package/dist/hooks/use-cortena-theme.js +49 -3
  94. package/dist/hooks/use-cortena-theme.js.map +1 -1
  95. package/dist/index.d.ts +17 -4
  96. package/dist/index.js +21 -8
  97. package/dist/markdown.d.ts +2 -1
  98. package/dist/markdown.js +2 -1
  99. package/package.json +16 -4
  100. package/src/agent-chat/a2ui-block.ts +118 -0
  101. package/src/agent-chat/agui-client.ts +405 -0
  102. package/src/agent-chat/bridge.ts +433 -0
  103. package/src/agent-chat/session.ts +392 -0
  104. package/src/agent-chat/store.ts +738 -0
  105. package/src/agent-chat/types.ts +213 -0
  106. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  107. package/src/components/admin-permissions/context.tsx +376 -0
  108. package/src/components/admin-permissions/index.tsx +32 -0
  109. package/src/components/admin-permissions/licence.tsx +84 -0
  110. package/src/components/admin-permissions/matrix.tsx +257 -0
  111. package/src/components/admin-permissions/members.tsx +204 -0
  112. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  113. package/src/components/admin-permissions/roles.tsx +169 -0
  114. package/src/components/admin-permissions/types.ts +231 -0
  115. package/src/components/agent-chat-popup.tsx +289 -0
  116. package/src/components/agent-chat.tsx +843 -0
  117. package/src/components/app-shell.tsx +502 -0
  118. package/src/components/consent-screen.tsx +239 -0
  119. package/src/components/data-table/data-table.tsx +36 -0
  120. package/src/components/data-table/index.tsx +6 -1
  121. package/src/components/data-table/parts.tsx +223 -47
  122. package/src/components/data-table/types.ts +68 -0
  123. package/src/components/data-table/use-data-table.ts +152 -4
  124. package/src/components/data-table/use-server-source.ts +150 -12
  125. package/src/components/help-panel.tsx +765 -0
  126. package/src/components/login-screen.tsx +479 -0
  127. package/src/components/session-guard.tsx +1071 -0
  128. package/src/entries/agent-chat.ts +113 -0
  129. package/src/entries/core.ts +8 -0
  130. package/src/entries/data-table.ts +41 -0
  131. package/src/entries/markdown.ts +25 -0
  132. package/src/hooks/use-cortena-theme.ts +63 -4
  133. package/src/index.ts +6 -0
@@ -0,0 +1,738 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The chat state, and the hook that drives an `AgentChatClient` from React.
5
+ *
6
+ * The accumulation rules are ported from `cortena-shared/src/stores/use-chat.ts`
7
+ * (Cortena monorepo) so a run leaves this state in the same place it leaves
8
+ * cortenaweb's. The ones that are not obvious, and that a rewrite gets wrong:
9
+ *
10
+ * - A `delta` carries a snapshot, so text and A2UI blocks are **replaced**, not
11
+ * appended. Appending doubles every character.
12
+ * - Thinking is never replaced by something shorter. The upstream emitter can
13
+ * fall back to increments when a snapshot is unavailable, and treating one of
14
+ * those as a snapshot truncates the accumulated chain of thought.
15
+ * - On `final`, a second assistant message in the same turn folds the previous
16
+ * visible text into the chain of thought rather than adding a second bubble,
17
+ * and keeps the A2UI blocks of the message it folded away — the surface is
18
+ * still on screen and still interactive.
19
+ * - A reply that is only a drawn surface has no text, so `text || a2ui.length`
20
+ * is the test for "there is something to show". Testing text alone silently
21
+ * dropped everything the agent drew.
22
+ */
23
+
24
+ import * as React from "react";
25
+ import { type A2UIChatBlock, extractA2UIBlocks, foldA2UIBlocks } from "./a2ui-block";
26
+ import {
27
+ isProvisionalSessionKey,
28
+ isSessionOfAgent,
29
+ mintSessionKey,
30
+ parseHistoryMessages,
31
+ readStoredSessionKey,
32
+ separateThinking,
33
+ wouldShrinkHistory,
34
+ writeStoredSessionKey,
35
+ } from "./session";
36
+ import type {
37
+ AgentApprovalDecision,
38
+ AgentApprovalRequest,
39
+ AgentChatClient,
40
+ AgentChatEvent,
41
+ AgentChatHistoryResponse,
42
+ AgentChatMessage,
43
+ AgentSession,
44
+ AgentSessionsListResponse,
45
+ AgentToolEvent,
46
+ } from "./types";
47
+
48
+ export interface AgentToolEntry {
49
+ id: string;
50
+ name: string;
51
+ status: "running" | "complete" | "error";
52
+ input?: unknown;
53
+ output?: unknown;
54
+ startedAt: number;
55
+ completedAt?: number;
56
+ }
57
+
58
+ export interface AgentChatState {
59
+ sessionKey: string | null;
60
+ messages: AgentChatMessage[];
61
+ streaming: boolean;
62
+ streamText: string;
63
+ streamThinkingText: string;
64
+ streamA2ui: A2UIChatBlock[];
65
+ runId: string | null;
66
+ hasMoreHistory: boolean;
67
+ loadingHistory: boolean;
68
+ toolOrder: string[];
69
+ tools: Record<string, AgentToolEntry>;
70
+ approvals: AgentApprovalRequest[];
71
+ /**
72
+ * Transcript RECORDS loaded, which is not `messages.length`.
73
+ *
74
+ * `parseHistoryMessages` folds a turn — an assistant preamble, the tool call,
75
+ * the tool result, the answer — into one visible message, so the two numbers
76
+ * diverge on the very first agent turn. `offset` is an offset into the
77
+ * server's records, and paging with the folded count asked for records the
78
+ * screen already had and skipped the ones it did not: "load older" silently
79
+ * lost messages the further back a user read.
80
+ */
81
+ historyCount: number;
82
+ sessions: AgentSession[];
83
+ loadingSessions: boolean;
84
+ /** A failure the user has to be told about, e.g. no template for the slug. */
85
+ error: string | null;
86
+ }
87
+
88
+ const EMPTY: AgentChatState = {
89
+ sessionKey: null,
90
+ messages: [],
91
+ streaming: false,
92
+ streamText: "",
93
+ streamThinkingText: "",
94
+ streamA2ui: [],
95
+ runId: null,
96
+ hasMoreHistory: false,
97
+ loadingHistory: false,
98
+ toolOrder: [],
99
+ tools: {},
100
+ approvals: [],
101
+ historyCount: 0,
102
+ sessions: [],
103
+ loadingSessions: false,
104
+ error: null,
105
+ };
106
+
107
+ type Action =
108
+ | { kind: "chat"; event: AgentChatEvent }
109
+ | { kind: "tool"; event: AgentToolEvent }
110
+ | { kind: "approval/request"; request: AgentApprovalRequest }
111
+ | { kind: "approval/resolved"; id: string }
112
+ | { kind: "approval/failed"; request: AgentApprovalRequest; message: string }
113
+ | { kind: "session"; key: string | null }
114
+ | { kind: "send"; message: AgentChatMessage | null; runId: string; sessionKey: string }
115
+ | { kind: "send/failed"; message: string }
116
+ | { kind: "abort" }
117
+ | { kind: "history/loading" }
118
+ | {
119
+ kind: "history/loaded";
120
+ sessionKey: string;
121
+ messages: AgentChatMessage[];
122
+ hasMore: boolean;
123
+ records: number;
124
+ }
125
+ | { kind: "history/older"; messages: AgentChatMessage[]; hasMore: boolean; records: number }
126
+ | { kind: "history/failed" }
127
+ | { kind: "sessions/loading" }
128
+ | { kind: "sessions/loaded"; sessions: AgentSession[] }
129
+ | { kind: "error"; message: string | null };
130
+
131
+ function generateId(): string {
132
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
133
+ }
134
+
135
+ function extractText(message?: AgentChatEvent["message"]): string {
136
+ return (message?.content ?? [])
137
+ .filter((c) => c.type === "text" && c.text)
138
+ .map((c) => c.text!)
139
+ .join("");
140
+ }
141
+
142
+ function extractThinking(message?: AgentChatEvent["message"]): string {
143
+ const blocks = (message?.content ?? [])
144
+ .filter((c) => c.type === "thinking")
145
+ .map((c) => c.thinking ?? c.text ?? "")
146
+ .filter(Boolean);
147
+ return blocks.join("\n\n");
148
+ }
149
+
150
+ function reduce(state: AgentChatState, action: Action): AgentChatState {
151
+ switch (action.kind) {
152
+ case "chat":
153
+ return reduceChat(state, action.event);
154
+ case "tool":
155
+ return reduceTool(state, action.event);
156
+ case "approval/request":
157
+ return state.approvals.some((r) => r.id === action.request.id)
158
+ ? state
159
+ : { ...state, approvals: [...state.approvals, action.request] };
160
+ case "approval/resolved":
161
+ return { ...state, approvals: state.approvals.filter((r) => r.id !== action.id) };
162
+ case "approval/failed":
163
+ // The decision did not land, so the card comes back — with the reason on
164
+ // it. Removing it optimistically and then swallowing the failure left
165
+ // the user certain they had answered and the run paused for ever.
166
+ return state.approvals.some((r) => r.id === action.request.id)
167
+ ? state
168
+ : {
169
+ ...state,
170
+ approvals: [...state.approvals, { ...action.request, error: action.message }],
171
+ };
172
+ case "session":
173
+ return action.key === state.sessionKey
174
+ ? state
175
+ : { ...EMPTY, sessions: state.sessions, sessionKey: action.key };
176
+ case "send":
177
+ return {
178
+ ...state,
179
+ sessionKey: action.sessionKey,
180
+ messages: action.message ? [...state.messages, action.message] : state.messages,
181
+ streaming: true,
182
+ streamText: "",
183
+ streamThinkingText: "",
184
+ streamA2ui: [],
185
+ runId: action.runId,
186
+ toolOrder: [],
187
+ tools: {},
188
+ // A new run is a new set of pending decisions. An approval card left
189
+ // over from the previous run points at a run that is over, and
190
+ // answering it sends a decision nothing is waiting for.
191
+ approvals: [],
192
+ error: null,
193
+ };
194
+ case "send/failed":
195
+ return {
196
+ ...state,
197
+ streaming: false,
198
+ runId: null,
199
+ messages: [
200
+ ...state.messages,
201
+ {
202
+ id: generateId(),
203
+ role: "assistant",
204
+ text: action.message,
205
+ timestamp: Date.now(),
206
+ isError: true,
207
+ },
208
+ ],
209
+ };
210
+ case "abort":
211
+ // Reset immediately so the composer flips Stop -> Send without waiting on
212
+ // a server event an aborted run does not reliably send.
213
+ return {
214
+ ...state,
215
+ streaming: false,
216
+ streamText: "",
217
+ streamThinkingText: "",
218
+ streamA2ui: [],
219
+ runId: null,
220
+ // The run they belonged to is gone; the decisions go with it.
221
+ approvals: [],
222
+ };
223
+ case "history/loading":
224
+ return { ...state, loadingHistory: true };
225
+ case "history/loaded":
226
+ if (
227
+ wouldShrinkHistory({
228
+ currentSessionKey: state.sessionKey,
229
+ loadedSessionKey: action.sessionKey,
230
+ currentCount: state.messages.length,
231
+ loadedCount: action.messages.length,
232
+ })
233
+ ) {
234
+ return {
235
+ ...state,
236
+ sessionKey: action.sessionKey,
237
+ hasMoreHistory: action.hasMore,
238
+ loadingHistory: false,
239
+ historyCount: action.records,
240
+ };
241
+ }
242
+ return {
243
+ ...state,
244
+ sessionKey: action.sessionKey,
245
+ messages: action.messages,
246
+ hasMoreHistory: action.hasMore,
247
+ loadingHistory: false,
248
+ historyCount: action.records,
249
+ // Avoids an orphan streaming bubble beside reloaded history.
250
+ streaming: false,
251
+ streamText: "",
252
+ streamThinkingText: "",
253
+ streamA2ui: [],
254
+ };
255
+ case "history/older":
256
+ return {
257
+ ...state,
258
+ messages: [...action.messages, ...state.messages],
259
+ hasMoreHistory: action.hasMore,
260
+ loadingHistory: false,
261
+ historyCount: state.historyCount + action.records,
262
+ };
263
+ case "history/failed":
264
+ return { ...state, loadingHistory: false };
265
+ case "sessions/loading":
266
+ return { ...state, loadingSessions: true };
267
+ case "sessions/loaded":
268
+ return { ...state, sessions: action.sessions, loadingSessions: false };
269
+ case "error":
270
+ return { ...state, error: action.message };
271
+ default:
272
+ return state;
273
+ }
274
+ }
275
+
276
+ function reduceChat(state: AgentChatState, event: AgentChatEvent): AgentChatState {
277
+ // Events from another session are dropped, or one window's run leaks into
278
+ // another window's transcript. A provisional key is the exception: the server
279
+ // echoes it back today, but if it ever canonicalised the key instead, dropping
280
+ // the event would silently kill the whole first turn.
281
+ if (event.sessionKey && state.sessionKey && event.sessionKey !== state.sessionKey) {
282
+ if (!isProvisionalSessionKey(state.sessionKey)) {
283
+ return state;
284
+ }
285
+ state = { ...state, sessionKey: event.sessionKey };
286
+ } else if (event.sessionKey && !state.sessionKey) {
287
+ state = { ...state, sessionKey: event.sessionKey };
288
+ }
289
+
290
+ switch (event.state) {
291
+ case "delta": {
292
+ const text = extractText(event.message);
293
+ const thinking = extractThinking(event.message);
294
+ const a2ui = extractA2UIBlocks(event.message);
295
+ const next: AgentChatState = { ...state, streaming: true };
296
+ if (text) {
297
+ next.streamText = text;
298
+ }
299
+ if (a2ui.length > 0) {
300
+ next.streamA2ui = a2ui;
301
+ }
302
+ // Never replace accumulated thinking with something shorter.
303
+ const acceptThinking = !!thinking && thinking.length >= state.streamThinkingText.length;
304
+ if (acceptThinking) {
305
+ next.streamThinkingText = thinking;
306
+ }
307
+ return text || acceptThinking || a2ui.length > 0 ? next : state;
308
+ }
309
+ case "final": {
310
+ const finalText = extractText(event.message);
311
+ const finalThinking = extractThinking(event.message);
312
+ const finalA2ui = extractA2UIBlocks(event.message);
313
+ const text = finalText || state.streamText;
314
+ const thinkingFromBlocks = finalThinking || state.streamThinkingText;
315
+ const a2ui = foldA2UIBlocks(finalA2ui.length > 0 ? finalA2ui : state.streamA2ui);
316
+ // The run id deliberately SURVIVES a final. A run can end more than
317
+ // once — an interrupt for an approval, then the continuation — and
318
+ // clearing it here meant the second final saw `runId: null`, could not
319
+ // fold, and pushed a second bubble: one answer to one question, drawn
320
+ // as two. `send` sets the next run's id, and `abort`, `error` and
321
+ // `send/failed` each clear it, so nothing outlives the turn it belongs
322
+ // to.
323
+ const cleared = {
324
+ streaming: false,
325
+ streamText: "",
326
+ streamThinkingText: "",
327
+ streamA2ui: [] as A2UIChatBlock[],
328
+ };
329
+
330
+ if (!text && a2ui.length === 0) {
331
+ // No text and nothing drawn: fold whatever thinking accumulated into
332
+ // the previous turn rather than dropping it.
333
+ const accumulated = state.streamThinkingText;
334
+ const last = state.messages[state.messages.length - 1];
335
+ if (!accumulated || !foldsInto(last, state.runId)) {
336
+ return { ...state, ...cleared };
337
+ }
338
+ const messages = [...state.messages];
339
+ messages[messages.length - 1] = {
340
+ ...last,
341
+ timestamp: Date.now(),
342
+ thinkingText: [last.thinkingText ?? "", accumulated].filter(Boolean).join("\n\n"),
343
+ };
344
+ return { ...state, ...cleared, messages };
345
+ }
346
+
347
+ const { thinkingText: tagThinking, mainText } = separateThinking(text);
348
+ const thinkingText = thinkingFromBlocks || tagThinking;
349
+ const displayText = thinkingFromBlocks ? text : mainText;
350
+ const messages = [...state.messages];
351
+ const last = messages[messages.length - 1];
352
+
353
+ if (foldsInto(last, state.runId)) {
354
+ const combined = [last.thinkingText ?? "", last.text.trim()].filter(Boolean).join("\n\n");
355
+ const updated = [combined, thinkingText].filter(Boolean).join("\n\n");
356
+ messages[messages.length - 1] = {
357
+ ...last,
358
+ text: displayText,
359
+ timestamp: Date.now(),
360
+ ...(updated ? { thinkingText: updated } : { thinkingText: undefined }),
361
+ ...(a2ui.length > 0 || last.a2ui?.length
362
+ ? { a2ui: foldA2UIBlocks([...(last.a2ui ?? []), ...a2ui]) }
363
+ : {}),
364
+ };
365
+ } else {
366
+ messages.push({
367
+ id: generateId(),
368
+ role: "assistant",
369
+ text: displayText,
370
+ timestamp: Date.now(),
371
+ // The run this bubble belongs to, so the NEXT run's `final` cannot
372
+ // fold its answer into it.
373
+ ...(state.runId ? { runId: state.runId } : {}),
374
+ ...(thinkingText ? { thinkingText } : {}),
375
+ ...(a2ui.length > 0 ? { a2ui } : {}),
376
+ });
377
+ }
378
+ return { ...state, ...cleared, messages };
379
+ }
380
+ case "error":
381
+ return {
382
+ ...state,
383
+ streaming: false,
384
+ streamText: "",
385
+ streamThinkingText: "",
386
+ streamA2ui: [],
387
+ runId: null,
388
+ messages: [
389
+ ...state.messages,
390
+ {
391
+ id: generateId(),
392
+ role: "assistant",
393
+ text: event.errorMessage ?? "An error occurred",
394
+ timestamp: Date.now(),
395
+ isError: true,
396
+ },
397
+ ],
398
+ };
399
+ default:
400
+ return state;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * May a `final` fold into this message?
406
+ *
407
+ * Only when it is an assistant bubble from the SAME run. Two assistant
408
+ * messages in one turn are one answer with an interruption in the middle, and
409
+ * folding them is right. Two assistant messages from different runs are two
410
+ * answers to two questions, and folding them rewrote the earlier answer with
411
+ * the later one — visibly, in a transcript the user had already read.
412
+ *
413
+ * A message with no `runId` is history, which never folds either.
414
+ */
415
+ function foldsInto(
416
+ last: AgentChatMessage | undefined,
417
+ runId: string | null,
418
+ ): last is AgentChatMessage {
419
+ return (
420
+ last !== undefined &&
421
+ last.role === "assistant" &&
422
+ !last.isError &&
423
+ runId !== null &&
424
+ last.runId === runId
425
+ );
426
+ }
427
+
428
+ function reduceTool(state: AgentChatState, event: AgentToolEvent): AgentChatState {
429
+ const id = event.data.id;
430
+ if (!id) {
431
+ return state;
432
+ }
433
+ const existing = state.tools[id];
434
+ const base: AgentToolEntry = existing ?? {
435
+ id,
436
+ name: event.data.name ?? "tool",
437
+ status: "running",
438
+ startedAt: event.data.startedAt ?? Date.now(),
439
+ };
440
+ let entry: AgentToolEntry;
441
+ switch (event.event) {
442
+ case "tool.start":
443
+ entry = { ...base, name: event.data.name ?? base.name, status: "running" };
444
+ break;
445
+ case "tool.args":
446
+ entry = { ...base, input: event.data.input };
447
+ break;
448
+ case "tool.progress":
449
+ entry = { ...base, output: event.data.output };
450
+ break;
451
+ case "tool.complete":
452
+ entry = {
453
+ ...base,
454
+ status: "complete",
455
+ output: event.data.output,
456
+ completedAt: event.data.completedAt ?? Date.now(),
457
+ };
458
+ break;
459
+ case "tool.error":
460
+ entry = {
461
+ ...base,
462
+ status: "error",
463
+ output: event.data.output,
464
+ completedAt: event.data.completedAt ?? Date.now(),
465
+ };
466
+ break;
467
+ default:
468
+ return state;
469
+ }
470
+ return {
471
+ ...state,
472
+ tools: { ...state.tools, [id]: entry },
473
+ toolOrder: state.toolOrder.includes(id) ? state.toolOrder : [...state.toolOrder, id],
474
+ };
475
+ }
476
+
477
+ /* ── the hook ────────────────────────────────────────────────────────────── */
478
+
479
+ export interface UseAgentChatOptions {
480
+ client: AgentChatClient;
481
+ /**
482
+ * Names the `sessionStorage` slot the current session key lives in. Two
483
+ * pop-ups with different scopes never share a key, which is what keeps one
484
+ * extension's chat out of another's.
485
+ */
486
+ storageScope: string;
487
+ /** Skip reading and writing `sessionStorage`; for a transient mount. */
488
+ ephemeral?: boolean;
489
+ thinking?: "low" | "medium" | "high";
490
+ }
491
+
492
+ export interface UseAgentChatResult extends AgentChatState {
493
+ send: (text: string, attachments?: unknown[]) => void;
494
+ sendAction: (action: NonNullable<Parameters<AgentChatClient["send"]>[0]["a2uiAction"]>) => void;
495
+ abort: () => void;
496
+ resolveApproval: (id: string, decision: AgentApprovalDecision) => void;
497
+ newSession: () => void;
498
+ resumeSession: (key: string) => void;
499
+ loadOlder: () => void;
500
+ refreshSessions: () => void;
501
+ }
502
+
503
+ export function useAgentChat({
504
+ client,
505
+ storageScope,
506
+ ephemeral,
507
+ thinking,
508
+ }: UseAgentChatOptions): UseAgentChatResult {
509
+ const [state, dispatch] = React.useReducer(reduce, EMPTY);
510
+ const stateRef = React.useRef(state);
511
+ stateRef.current = state;
512
+
513
+ // Subscribe once per client. A pop-up that collapses unmounts the surface but
514
+ // keeps the client, so a run started before the collapse still lands.
515
+ React.useEffect(() => {
516
+ const offChat = client.onChat((event) => dispatch({ kind: "chat", event }));
517
+ const offTool = client.onTool((event) => dispatch({ kind: "tool", event }));
518
+ const offApproval = client.onApproval((event) => {
519
+ if (event.type === "request") {
520
+ dispatch({ kind: "approval/request", request: event.request });
521
+ } else {
522
+ dispatch({ kind: "approval/resolved", id: event.id });
523
+ }
524
+ });
525
+ return () => {
526
+ offChat();
527
+ offTool();
528
+ offApproval();
529
+ };
530
+ }, [client]);
531
+
532
+ const refreshSessions = React.useCallback(() => {
533
+ dispatch({ kind: "sessions/loading" });
534
+ void client
535
+ .request<AgentSessionsListResponse>("sessions.list", { includeDerivedTitles: true })
536
+ .then((response) => {
537
+ // Filtered to this agent: a shared control plane lists every session on
538
+ // the pod, and an extension must never show another agent's chats.
539
+ const sessions = (response?.sessions ?? []).filter((s) =>
540
+ isSessionOfAgent(s.key, client.agentId),
541
+ );
542
+ dispatch({ kind: "sessions/loaded", sessions });
543
+ })
544
+ .catch((err: unknown) => {
545
+ dispatch({ kind: "sessions/loaded", sessions: [] });
546
+ dispatch({ kind: "error", message: errorText(err) });
547
+ });
548
+ }, [client]);
549
+
550
+ const loadHistory = React.useCallback(
551
+ (sessionKey: string) => {
552
+ dispatch({ kind: "history/loading" });
553
+ void client
554
+ .request<AgentChatHistoryResponse>("chat.history", { sessionKey, limit: 100 })
555
+ .then((response) => {
556
+ const records = response?.messages ?? [];
557
+ dispatch({
558
+ kind: "history/loaded",
559
+ sessionKey,
560
+ messages: parseHistoryMessages(records),
561
+ hasMore: response?.hasMore ?? false,
562
+ records: records.length,
563
+ });
564
+ })
565
+ .catch(() => dispatch({ kind: "history/failed" }));
566
+ },
567
+ [client],
568
+ );
569
+
570
+ // Restore this window's session, or mint one. `sessionStorage`, not
571
+ // `localStorage`: a second window must not adopt the first window's session.
572
+ const restored = React.useRef(false);
573
+ React.useEffect(() => {
574
+ if (restored.current) {
575
+ return;
576
+ }
577
+ restored.current = true;
578
+ const stored = ephemeral ? null : readStoredSessionKey(storageScope);
579
+ const key = stored && isSessionOfAgent(stored, client.agentId) ? stored : null;
580
+ if (key) {
581
+ dispatch({ kind: "session", key });
582
+ loadHistory(key);
583
+ }
584
+ refreshSessions();
585
+ }, [client, ephemeral, loadHistory, refreshSessions, storageScope]);
586
+
587
+ const setSession = React.useCallback(
588
+ (key: string) => {
589
+ dispatch({ kind: "session", key });
590
+ if (!ephemeral) {
591
+ writeStoredSessionKey(storageScope, key);
592
+ }
593
+ },
594
+ [ephemeral, storageScope],
595
+ );
596
+
597
+ const run = React.useCallback(
598
+ (text: string, extra: Partial<Parameters<AgentChatClient["send"]>[0]> = {}) => {
599
+ const current = stateRef.current;
600
+ // One run at a time. A second `send` on a streaming session opens a
601
+ // second stream into one store: two sets of deltas overwrite each
602
+ // other's snapshot, `final` folds one run's answer into the other's
603
+ // bubble, and `abort` can only reach whichever the client thinks is
604
+ // active. The composer disables Send while a run is live; this is the
605
+ // same rule for Enter, for a2ui actions and for a caller of the hook.
606
+ if (current.streaming) {
607
+ return;
608
+ }
609
+ const sessionKey = current.sessionKey ?? mintSessionKey(client.agentId);
610
+ if (!current.sessionKey && !ephemeral) {
611
+ writeStoredSessionKey(storageScope, sessionKey);
612
+ }
613
+ const runId = generateId();
614
+ // An action with no typed text is a click, not a sentence: a user bubble
615
+ // for it would put `{"name":"createTask"}` in the transcript as though
616
+ // the user had said it.
617
+ const message: AgentChatMessage | null =
618
+ text || (!extra.a2uiAction && !extra.mcpAppAction)
619
+ ? { id: runId, role: "user", text, timestamp: Date.now(), runId }
620
+ : null;
621
+ dispatch({ kind: "send", message, runId, sessionKey });
622
+ void client
623
+ .send({
624
+ sessionKey,
625
+ message: text,
626
+ runId,
627
+ ...(thinking ? { thinking } : {}),
628
+ ...extra,
629
+ })
630
+ .then(() => refreshSessions())
631
+ .catch((err: unknown) => dispatch({ kind: "send/failed", message: errorText(err) }));
632
+ },
633
+ [client, ephemeral, refreshSessions, storageScope, thinking],
634
+ );
635
+
636
+ const send = React.useCallback(
637
+ (text: string, attachments?: unknown[]) => {
638
+ run(text, attachments?.length ? { attachments } : {});
639
+ },
640
+ [run],
641
+ );
642
+
643
+ const sendAction = React.useCallback(
644
+ (a2uiAction: NonNullable<Parameters<AgentChatClient["send"]>[0]["a2uiAction"]>) => {
645
+ run("", { a2uiAction });
646
+ },
647
+ [run],
648
+ );
649
+
650
+ const abort = React.useCallback(() => {
651
+ const { runId, sessionKey } = stateRef.current;
652
+ dispatch({ kind: "abort" });
653
+ void client
654
+ .abort({
655
+ ...(runId ? { runId } : {}),
656
+ ...(sessionKey ? { sessionKey } : {}),
657
+ })
658
+ .catch(() => {
659
+ // Best-effort: local state is already reset, and dropping the stream
660
+ // aborts the run server-side anyway.
661
+ });
662
+ }, [client]);
663
+
664
+ const resolveApproval = React.useCallback(
665
+ (id: string, decision: AgentApprovalDecision) => {
666
+ const request = stateRef.current.approvals.find((r) => r.id === id);
667
+ if (!request) {
668
+ return;
669
+ }
670
+ dispatch({ kind: "approval/resolved", id });
671
+ // Bound to the run and the session the REQUEST arrived on, never to
672
+ // whatever is in flight now. The two come apart the moment a user opens
673
+ // another session while a command waits for an answer.
674
+ void client
675
+ .resolveApproval(id, decision, {
676
+ runId: request.runId,
677
+ sessionKey: request.sessionKey,
678
+ })
679
+ .catch((err: unknown) => {
680
+ dispatch({ kind: "approval/failed", request, message: errorText(err) });
681
+ });
682
+ },
683
+ [client],
684
+ );
685
+
686
+ const newSession = React.useCallback(() => {
687
+ setSession(mintSessionKey(client.agentId));
688
+ }, [client, setSession]);
689
+
690
+ const resumeSession = React.useCallback(
691
+ (key: string) => {
692
+ setSession(key);
693
+ loadHistory(key);
694
+ },
695
+ [loadHistory, setSession],
696
+ );
697
+
698
+ const loadOlder = React.useCallback(() => {
699
+ const current = stateRef.current;
700
+ if (!current.sessionKey || current.loadingHistory || !current.hasMoreHistory) {
701
+ return;
702
+ }
703
+ dispatch({ kind: "history/loading" });
704
+ void client
705
+ .request<AgentChatHistoryResponse>("chat.history", {
706
+ sessionKey: current.sessionKey,
707
+ limit: 100,
708
+ // Records, not bubbles. See `historyCount`.
709
+ offset: current.historyCount,
710
+ })
711
+ .then((response) => {
712
+ const records = response?.messages ?? [];
713
+ dispatch({
714
+ kind: "history/older",
715
+ messages: parseHistoryMessages(records),
716
+ hasMore: response?.hasMore ?? false,
717
+ records: records.length,
718
+ });
719
+ })
720
+ .catch(() => dispatch({ kind: "history/failed" }));
721
+ }, [client]);
722
+
723
+ return {
724
+ ...state,
725
+ send,
726
+ sendAction,
727
+ abort,
728
+ resolveApproval,
729
+ newSession,
730
+ resumeSession,
731
+ loadOlder,
732
+ refreshSessions,
733
+ };
734
+ }
735
+
736
+ function errorText(err: unknown): string {
737
+ return err instanceof Error ? err.message : "The agent could not be reached";
738
+ }