cortena-ui 1.4.2 → 1.6.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 (138) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/LICENSE +7 -0
  3. package/README.md +235 -3
  4. package/dist/a2ui/views.js +2 -2
  5. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  6. package/dist/agent-chat/a2ui-block.js +69 -0
  7. package/dist/agent-chat/a2ui-block.js.map +1 -0
  8. package/dist/agent-chat/agui-client.d.ts +40 -0
  9. package/dist/agent-chat/agui-client.js +251 -0
  10. package/dist/agent-chat/agui-client.js.map +1 -0
  11. package/dist/agent-chat/bridge.d.ts +109 -0
  12. package/dist/agent-chat/bridge.js +353 -0
  13. package/dist/agent-chat/bridge.js.map +1 -0
  14. package/dist/agent-chat/session.d.ts +79 -0
  15. package/dist/agent-chat/session.js +391 -0
  16. package/dist/agent-chat/session.js.map +1 -0
  17. package/dist/agent-chat/step-label.d.ts +99 -0
  18. package/dist/agent-chat/step-label.js +116 -0
  19. package/dist/agent-chat/step-label.js.map +1 -0
  20. package/dist/agent-chat/store.d.ts +102 -0
  21. package/dist/agent-chat/store.js +876 -0
  22. package/dist/agent-chat/store.js.map +1 -0
  23. package/dist/agent-chat/types.d.ts +277 -0
  24. package/dist/agent-chat/types.js +17 -0
  25. package/dist/agent-chat/types.js.map +1 -0
  26. package/dist/agent-chat.d.ts +11 -0
  27. package/dist/agent-chat.js +11 -0
  28. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  29. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  30. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  31. package/dist/components/admin-permissions/context.d.ts +70 -0
  32. package/dist/components/admin-permissions/context.js +258 -0
  33. package/dist/components/admin-permissions/context.js.map +1 -0
  34. package/dist/components/admin-permissions/index.d.ts +10 -0
  35. package/dist/components/admin-permissions/licence.d.ts +15 -0
  36. package/dist/components/admin-permissions/licence.js +78 -0
  37. package/dist/components/admin-permissions/licence.js.map +1 -0
  38. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  39. package/dist/components/admin-permissions/matrix.js +191 -0
  40. package/dist/components/admin-permissions/matrix.js.map +1 -0
  41. package/dist/components/admin-permissions/members.d.ts +18 -0
  42. package/dist/components/admin-permissions/members.js +185 -0
  43. package/dist/components/admin-permissions/members.js.map +1 -0
  44. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  45. package/dist/components/admin-permissions/role-assignment.js +174 -0
  46. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  47. package/dist/components/admin-permissions/roles.d.ts +25 -0
  48. package/dist/components/admin-permissions/roles.js +168 -0
  49. package/dist/components/admin-permissions/roles.js.map +1 -0
  50. package/dist/components/admin-permissions/types.d.ts +152 -0
  51. package/dist/components/admin-permissions/types.js +63 -0
  52. package/dist/components/admin-permissions/types.js.map +1 -0
  53. package/dist/components/agent-chat-popup.d.ts +29 -0
  54. package/dist/components/agent-chat-popup.js +188 -0
  55. package/dist/components/agent-chat-popup.js.map +1 -0
  56. package/dist/components/agent-chat.d.ts +163 -0
  57. package/dist/components/agent-chat.js +673 -0
  58. package/dist/components/agent-chat.js.map +1 -0
  59. package/dist/components/app-shell.d.ts +126 -0
  60. package/dist/components/app-shell.js +297 -0
  61. package/dist/components/app-shell.js.map +1 -0
  62. package/dist/components/badge.d.ts +1 -1
  63. package/dist/components/button-link.js +1 -1
  64. package/dist/components/button.d.ts +1 -1
  65. package/dist/components/checkbox.d.ts +1 -1
  66. package/dist/components/combobox.d.ts +1 -1
  67. package/dist/components/combobox.js +1 -1
  68. package/dist/components/consent-screen.d.ts +65 -0
  69. package/dist/components/consent-screen.js +123 -0
  70. package/dist/components/consent-screen.js.map +1 -0
  71. package/dist/components/data-table/data-table.d.ts +15 -1
  72. package/dist/components/data-table/data-table.js +18 -4
  73. package/dist/components/data-table/data-table.js.map +1 -1
  74. package/dist/components/data-table/index.d.ts +4 -4
  75. package/dist/components/data-table/parts.d.ts +27 -3
  76. package/dist/components/data-table/parts.js +175 -55
  77. package/dist/components/data-table/parts.js.map +1 -1
  78. package/dist/components/data-table/types.d.ts +61 -0
  79. package/dist/components/data-table/use-data-table.js +91 -6
  80. package/dist/components/data-table/use-data-table.js.map +1 -1
  81. package/dist/components/data-table/use-server-source.js +119 -28
  82. package/dist/components/data-table/use-server-source.js.map +1 -1
  83. package/dist/components/help-panel.d.ts +131 -0
  84. package/dist/components/help-panel.js +545 -0
  85. package/dist/components/help-panel.js.map +1 -0
  86. package/dist/components/login-screen.d.ts +127 -0
  87. package/dist/components/login-screen.js +339 -0
  88. package/dist/components/login-screen.js.map +1 -0
  89. package/dist/components/session-guard.d.ts +268 -0
  90. package/dist/components/session-guard.js +632 -0
  91. package/dist/components/session-guard.js.map +1 -0
  92. package/dist/components/toast.d.ts +1 -1
  93. package/dist/core.d.ts +5 -1
  94. package/dist/core.js +11 -7
  95. package/dist/data-table.d.ts +13 -4
  96. package/dist/data-table.js +10 -2
  97. package/dist/hooks/use-cortena-theme.js +49 -3
  98. package/dist/hooks/use-cortena-theme.js.map +1 -1
  99. package/dist/index.d.ts +17 -4
  100. package/dist/index.js +21 -8
  101. package/dist/markdown.d.ts +2 -1
  102. package/dist/markdown.js +2 -1
  103. package/package.json +18 -5
  104. package/src/agent-chat/a2ui-block.ts +118 -0
  105. package/src/agent-chat/agui-client.ts +405 -0
  106. package/src/agent-chat/bridge.ts +445 -0
  107. package/src/agent-chat/session.ts +549 -0
  108. package/src/agent-chat/step-label.ts +177 -0
  109. package/src/agent-chat/store.ts +1234 -0
  110. package/src/agent-chat/types.ts +308 -0
  111. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  112. package/src/components/admin-permissions/context.tsx +376 -0
  113. package/src/components/admin-permissions/index.tsx +32 -0
  114. package/src/components/admin-permissions/licence.tsx +84 -0
  115. package/src/components/admin-permissions/matrix.tsx +257 -0
  116. package/src/components/admin-permissions/members.tsx +204 -0
  117. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  118. package/src/components/admin-permissions/roles.tsx +169 -0
  119. package/src/components/admin-permissions/types.ts +231 -0
  120. package/src/components/agent-chat-popup.tsx +289 -0
  121. package/src/components/agent-chat.tsx +1006 -0
  122. package/src/components/app-shell.tsx +502 -0
  123. package/src/components/consent-screen.tsx +239 -0
  124. package/src/components/data-table/data-table.tsx +36 -0
  125. package/src/components/data-table/index.tsx +6 -1
  126. package/src/components/data-table/parts.tsx +223 -47
  127. package/src/components/data-table/types.ts +68 -0
  128. package/src/components/data-table/use-data-table.ts +152 -4
  129. package/src/components/data-table/use-server-source.ts +150 -12
  130. package/src/components/help-panel.tsx +765 -0
  131. package/src/components/login-screen.tsx +479 -0
  132. package/src/components/session-guard.tsx +1071 -0
  133. package/src/entries/agent-chat.ts +137 -0
  134. package/src/entries/core.ts +8 -0
  135. package/src/entries/data-table.ts +41 -0
  136. package/src/entries/markdown.ts +25 -0
  137. package/src/hooks/use-cortena-theme.ts +63 -4
  138. package/src/index.ts +6 -0
@@ -0,0 +1,1234 @@
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
+ readStoredSteps,
33
+ separateThinking,
34
+ STEP_LIMIT,
35
+ wouldShrinkHistory,
36
+ writeStoredSessionKey,
37
+ writeStoredSteps,
38
+ } from "./session";
39
+ import { confirmationRequiredLabel, isGenericLabel, stepLabel, truncate } from "./step-label";
40
+ import type {
41
+ AgentApprovalDecision,
42
+ AgentApprovalRequest,
43
+ AgentChatClient,
44
+ AgentChatEvent,
45
+ AgentChatHistoryResponse,
46
+ AgentChatMessage,
47
+ AgentChatStep,
48
+ AgentSession,
49
+ AgentSessionsListResponse,
50
+ AgentToolEvent,
51
+ AgentToolResultMeta,
52
+ AgentToolResultMetadata,
53
+ } from "./types";
54
+
55
+ export interface AgentToolEntry {
56
+ id: string;
57
+ name: string;
58
+ status: "running" | "complete" | "error";
59
+ input?: unknown;
60
+ output?: unknown;
61
+ startedAt: number;
62
+ completedAt?: number;
63
+ }
64
+
65
+ export interface AgentChatState {
66
+ sessionKey: string | null;
67
+ messages: AgentChatMessage[];
68
+ streaming: boolean;
69
+ streamText: string;
70
+ streamThinkingText: string;
71
+ streamA2ui: A2UIChatBlock[];
72
+ runId: string | null;
73
+ hasMoreHistory: boolean;
74
+ loadingHistory: boolean;
75
+ toolOrder: string[];
76
+ tools: Record<string, AgentToolEntry>;
77
+ /**
78
+ * The turn's tool calls in plain words, in arrival order (SKILLS-9).
79
+ *
80
+ * The same calls as `tools`, worded rather than dumped, and rendered under
81
+ * the assistant turn instead of in the strip at the foot. Cleared when the
82
+ * next run starts, so the list is always "what is happening now" or "what
83
+ * just happened", never a transcript.
84
+ */
85
+ steps: AgentChatStep[];
86
+ /**
87
+ * Ids of steps the `STEP_LIMIT` cap has already dropped.
88
+ *
89
+ * A runaway turn pushes the first calls off the front of the list, and their
90
+ * results arrive afterwards. Without this the store saw an id it did not
91
+ * know, took it for a call nobody announced, and appended a fresh
92
+ * "Working: tool" line at the BOTTOM of the strip — the oldest call in the
93
+ * turn, drawn as the newest, with no words on it. Bounded, because it exists
94
+ * to recognise a handful of stragglers, not to keep a transcript.
95
+ */
96
+ retiredSteps: string[];
97
+ approvals: AgentApprovalRequest[];
98
+ /**
99
+ * Transcript RECORDS loaded, which is not `messages.length`.
100
+ *
101
+ * `parseHistoryMessages` folds a turn — an assistant preamble, the tool call,
102
+ * the tool result, the answer — into one visible message, so the two numbers
103
+ * diverge on the very first agent turn. `offset` is an offset into the
104
+ * server's records, and paging with the folded count asked for records the
105
+ * screen already had and skipped the ones it did not: "load older" silently
106
+ * lost messages the further back a user read.
107
+ */
108
+ historyCount: number;
109
+ sessions: AgentSession[];
110
+ loadingSessions: boolean;
111
+ /** A failure the user has to be told about, e.g. no template for the slug. */
112
+ error: string | null;
113
+ }
114
+
115
+ const EMPTY: AgentChatState = {
116
+ sessionKey: null,
117
+ messages: [],
118
+ streaming: false,
119
+ streamText: "",
120
+ streamThinkingText: "",
121
+ streamA2ui: [],
122
+ runId: null,
123
+ hasMoreHistory: false,
124
+ loadingHistory: false,
125
+ toolOrder: [],
126
+ tools: {},
127
+ steps: [],
128
+ retiredSteps: [],
129
+ approvals: [],
130
+ historyCount: 0,
131
+ sessions: [],
132
+ loadingSessions: false,
133
+ error: null,
134
+ };
135
+
136
+ type Action =
137
+ | { kind: "chat"; event: AgentChatEvent }
138
+ | { kind: "tool"; event: AgentToolEvent }
139
+ | { kind: "approval/request"; request: AgentApprovalRequest }
140
+ | { kind: "approval/resolved"; id: string }
141
+ | { kind: "approval/denied"; id: string }
142
+ | { kind: "approval/failed"; request: AgentApprovalRequest; message: string }
143
+ | { kind: "session"; key: string | null }
144
+ | { kind: "send"; message: AgentChatMessage | null; runId: string; sessionKey: string }
145
+ | { kind: "send/failed"; message: string }
146
+ | { kind: "abort" }
147
+ | { kind: "history/loading" }
148
+ | {
149
+ kind: "history/loaded";
150
+ sessionKey: string;
151
+ messages: AgentChatMessage[];
152
+ hasMore: boolean;
153
+ records: number;
154
+ }
155
+ | { kind: "history/older"; messages: AgentChatMessage[]; hasMore: boolean; records: number }
156
+ | { kind: "history/failed" }
157
+ | { kind: "sessions/loading" }
158
+ | { kind: "sessions/loaded"; sessions: AgentSession[] }
159
+ | { kind: "steps/restored"; steps: AgentChatStep[] }
160
+ | { kind: "error"; message: string | null };
161
+
162
+ function generateId(): string {
163
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
164
+ }
165
+
166
+ function extractText(message?: AgentChatEvent["message"]): string {
167
+ return (message?.content ?? [])
168
+ .filter((c) => c.type === "text" && c.text)
169
+ .map((c) => c.text!)
170
+ .join("");
171
+ }
172
+
173
+ function extractThinking(message?: AgentChatEvent["message"]): string {
174
+ const blocks = (message?.content ?? [])
175
+ .filter((c) => c.type === "thinking")
176
+ .map((c) => c.thinking ?? c.text ?? "")
177
+ .filter(Boolean);
178
+ return blocks.join("\n\n");
179
+ }
180
+
181
+ function reduce(state: AgentChatState, action: Action): AgentChatState {
182
+ switch (action.kind) {
183
+ case "chat":
184
+ return reduceChat(state, action.event);
185
+ case "tool":
186
+ return reduceTool(state, action.event);
187
+ case "approval/request":
188
+ return state.approvals.some((r) => r.id === action.request.id)
189
+ ? state
190
+ : { ...state, approvals: [...state.approvals, action.request] };
191
+ case "approval/resolved":
192
+ return { ...state, approvals: state.approvals.filter((r) => r.id !== action.id) };
193
+ case "approval/denied":
194
+ // The user said no. The call the run paused on is never going to happen,
195
+ // so it stops waiting — `stopped`, not `error`, for the same reason the
196
+ // Stop button is: nothing failed. Steps still RUNNING are untouched; a
197
+ // deny answers one question, it does not end the rest of the turn.
198
+ return {
199
+ ...state,
200
+ approvals: state.approvals.filter((r) => r.id !== action.id),
201
+ steps: endRunningSteps(state.steps, { kind: "denied" }),
202
+ };
203
+ case "approval/failed":
204
+ // The decision did not land, so the card comes back — with the reason on
205
+ // it. Removing it optimistically and then swallowing the failure left
206
+ // the user certain they had answered and the run paused for ever.
207
+ return state.approvals.some((r) => r.id === action.request.id)
208
+ ? state
209
+ : {
210
+ ...state,
211
+ approvals: [...state.approvals, { ...action.request, error: action.message }],
212
+ };
213
+ case "session":
214
+ return action.key === state.sessionKey
215
+ ? state
216
+ : { ...EMPTY, sessions: state.sessions, sessionKey: action.key };
217
+ case "send":
218
+ return {
219
+ ...state,
220
+ sessionKey: action.sessionKey,
221
+ messages: action.message ? [...state.messages, action.message] : state.messages,
222
+ streaming: true,
223
+ streamText: "",
224
+ streamThinkingText: "",
225
+ streamA2ui: [],
226
+ runId: action.runId,
227
+ toolOrder: [],
228
+ tools: {},
229
+ // The steps of the previous turn describe work that is over. Leaving
230
+ // them under the next answer reads as though the agent were still
231
+ // doing them.
232
+ steps: [],
233
+ retiredSteps: [],
234
+ // A new run is a new set of pending decisions. An approval card left
235
+ // over from the previous run points at a run that is over, and
236
+ // answering it sends a decision nothing is waiting for.
237
+ approvals: [],
238
+ error: null,
239
+ };
240
+ case "send/failed":
241
+ return {
242
+ ...state,
243
+ streaming: false,
244
+ runId: null,
245
+ messages: [
246
+ ...state.messages,
247
+ {
248
+ id: generateId(),
249
+ role: "assistant",
250
+ text: action.message,
251
+ timestamp: Date.now(),
252
+ isError: true,
253
+ },
254
+ ],
255
+ };
256
+ case "abort":
257
+ // Reset immediately so the composer flips Stop -> Send without waiting on
258
+ // a server event an aborted run does not reliably send.
259
+ return {
260
+ ...state,
261
+ streaming: false,
262
+ streamText: "",
263
+ streamThinkingText: "",
264
+ streamA2ui: [],
265
+ runId: null,
266
+ // The run they belonged to is gone; the decisions go with it.
267
+ approvals: [],
268
+ // A stopped run sends no end event for the call in flight, so a step
269
+ // left running spins for ever under an answer that will never come.
270
+ // Stopped, not failed: the user asked for it.
271
+ steps: endRunningSteps(state.steps, { kind: "stopped" }),
272
+ };
273
+ case "history/loading":
274
+ return { ...state, loadingHistory: true };
275
+ case "history/loaded":
276
+ if (
277
+ wouldShrinkHistory({
278
+ currentSessionKey: state.sessionKey,
279
+ loadedSessionKey: action.sessionKey,
280
+ currentCount: state.messages.length,
281
+ loadedCount: action.messages.length,
282
+ })
283
+ ) {
284
+ return {
285
+ ...state,
286
+ sessionKey: action.sessionKey,
287
+ hasMoreHistory: action.hasMore,
288
+ loadingHistory: false,
289
+ historyCount: action.records,
290
+ };
291
+ }
292
+ return {
293
+ ...state,
294
+ sessionKey: action.sessionKey,
295
+ messages: action.messages,
296
+ hasMoreHistory: action.hasMore,
297
+ loadingHistory: false,
298
+ historyCount: action.records,
299
+ // Avoids an orphan streaming bubble beside reloaded history.
300
+ streaming: false,
301
+ streamText: "",
302
+ streamThinkingText: "",
303
+ streamA2ui: [],
304
+ };
305
+ case "history/older":
306
+ return {
307
+ ...state,
308
+ messages: [...action.messages, ...state.messages],
309
+ hasMoreHistory: action.hasMore,
310
+ loadingHistory: false,
311
+ historyCount: state.historyCount + action.records,
312
+ };
313
+ case "history/failed":
314
+ return { ...state, loadingHistory: false };
315
+ case "sessions/loading":
316
+ return { ...state, loadingSessions: true };
317
+ case "sessions/loaded":
318
+ return { ...state, sessions: action.sessions, loadingSessions: false };
319
+ case "steps/restored":
320
+ // Only ever into an empty strip. A restore that arrives after the user
321
+ // has already started a new run would otherwise resurrect the last one.
322
+ return state.steps.length > 0 ? state : { ...state, steps: action.steps.slice(-STEP_LIMIT) };
323
+ case "error":
324
+ return { ...state, error: action.message };
325
+ default:
326
+ return state;
327
+ }
328
+ }
329
+
330
+ function reduceChat(state: AgentChatState, event: AgentChatEvent): AgentChatState {
331
+ // Events from another session are dropped, or one window's run leaks into
332
+ // another window's transcript. A provisional key is the exception: the server
333
+ // echoes it back today, but if it ever canonicalised the key instead, dropping
334
+ // the event would silently kill the whole first turn.
335
+ if (event.sessionKey && state.sessionKey && event.sessionKey !== state.sessionKey) {
336
+ if (!isProvisionalSessionKey(state.sessionKey)) {
337
+ return state;
338
+ }
339
+ state = { ...state, sessionKey: event.sessionKey };
340
+ } else if (event.sessionKey && !state.sessionKey) {
341
+ state = { ...state, sessionKey: event.sessionKey };
342
+ }
343
+
344
+ switch (event.state) {
345
+ case "delta": {
346
+ const text = extractText(event.message);
347
+ const thinking = extractThinking(event.message);
348
+ const a2ui = extractA2UIBlocks(event.message);
349
+ const next: AgentChatState = { ...state, streaming: true };
350
+ if (text) {
351
+ next.streamText = text;
352
+ }
353
+ if (a2ui.length > 0) {
354
+ next.streamA2ui = a2ui;
355
+ }
356
+ // Never replace accumulated thinking with something shorter.
357
+ const acceptThinking = !!thinking && thinking.length >= state.streamThinkingText.length;
358
+ if (acceptThinking) {
359
+ next.streamThinkingText = thinking;
360
+ }
361
+ return text || acceptThinking || a2ui.length > 0 ? next : state;
362
+ }
363
+ case "final": {
364
+ const finalText = extractText(event.message);
365
+ const finalThinking = extractThinking(event.message);
366
+ const finalA2ui = extractA2UIBlocks(event.message);
367
+ const text = finalText || state.streamText;
368
+ const thinkingFromBlocks = finalThinking || state.streamThinkingText;
369
+ const a2ui = foldA2UIBlocks(finalA2ui.length > 0 ? finalA2ui : state.streamA2ui);
370
+ // The run id deliberately SURVIVES a final. A run can end more than
371
+ // once — an interrupt for an approval, then the continuation — and
372
+ // clearing it here meant the second final saw `runId: null`, could not
373
+ // fold, and pushed a second bubble: one answer to one question, drawn
374
+ // as two. `send` sets the next run's id, and `abort`, `error` and
375
+ // `send/failed` each clear it, so nothing outlives the turn it belongs
376
+ // to.
377
+ const cleared = {
378
+ streaming: false,
379
+ streamText: "",
380
+ streamThinkingText: "",
381
+ streamA2ui: [] as A2UIChatBlock[],
382
+ // The run is over, so nothing is still running. A result event can go
383
+ // missing — a reconnect drops the tail — and the spinner then outlived
384
+ // the answer it was drawn beside. The label is untouched: a plain
385
+ // RUN_FINISHED means the run succeeded, so the step did too.
386
+ //
387
+ // An INTERRUPT is not that. cortenacore ends the run to ask for an
388
+ // approval or to hand a frontend tool to the client, and the tool call
389
+ // is still open: the continuation is a second run that ends it. Ticking
390
+ // it here claimed a write had gone through while the card asking
391
+ // permission for it was still on screen.
392
+ steps: endRunningSteps(
393
+ state.steps,
394
+ event.interrupted ? { kind: "paused" } : { kind: "finished" },
395
+ ),
396
+ };
397
+
398
+ if (!text && a2ui.length === 0) {
399
+ // No text and nothing drawn: fold whatever thinking accumulated into
400
+ // the previous turn rather than dropping it.
401
+ const accumulated = state.streamThinkingText;
402
+ const last = state.messages[state.messages.length - 1];
403
+ if (!accumulated || !foldsInto(last, state.runId)) {
404
+ return { ...state, ...cleared };
405
+ }
406
+ const messages = [...state.messages];
407
+ messages[messages.length - 1] = {
408
+ ...last,
409
+ timestamp: Date.now(),
410
+ thinkingText: [last.thinkingText ?? "", accumulated].filter(Boolean).join("\n\n"),
411
+ };
412
+ return { ...state, ...cleared, messages };
413
+ }
414
+
415
+ const { thinkingText: tagThinking, mainText } = separateThinking(text);
416
+ const thinkingText = thinkingFromBlocks || tagThinking;
417
+ const displayText = thinkingFromBlocks ? text : mainText;
418
+ const messages = [...state.messages];
419
+ const last = messages[messages.length - 1];
420
+
421
+ if (foldsInto(last, state.runId)) {
422
+ const combined = [last.thinkingText ?? "", last.text.trim()].filter(Boolean).join("\n\n");
423
+ const updated = [combined, thinkingText].filter(Boolean).join("\n\n");
424
+ messages[messages.length - 1] = {
425
+ ...last,
426
+ text: displayText,
427
+ timestamp: Date.now(),
428
+ ...(updated ? { thinkingText: updated } : { thinkingText: undefined }),
429
+ ...(a2ui.length > 0 || last.a2ui?.length
430
+ ? { a2ui: foldA2UIBlocks([...(last.a2ui ?? []), ...a2ui]) }
431
+ : {}),
432
+ };
433
+ } else {
434
+ messages.push({
435
+ id: generateId(),
436
+ role: "assistant",
437
+ text: displayText,
438
+ timestamp: Date.now(),
439
+ // The run this bubble belongs to, so the NEXT run's `final` cannot
440
+ // fold its answer into it.
441
+ ...(state.runId ? { runId: state.runId } : {}),
442
+ ...(thinkingText ? { thinkingText } : {}),
443
+ ...(a2ui.length > 0 ? { a2ui } : {}),
444
+ });
445
+ }
446
+ return { ...state, ...cleared, messages };
447
+ }
448
+ case "error":
449
+ return {
450
+ ...state,
451
+ streaming: false,
452
+ streamText: "",
453
+ streamThinkingText: "",
454
+ streamA2ui: [],
455
+ runId: null,
456
+ // A RUN_ERROR is the end of every call still in flight, whether or not
457
+ // the transport got round to sending a result for it.
458
+ steps: endRunningSteps(state.steps, {
459
+ kind: "failed",
460
+ message: truncate(event.errorMessage ?? "The run failed.", ERROR_LIMIT),
461
+ }),
462
+ messages: [
463
+ ...state.messages,
464
+ {
465
+ id: generateId(),
466
+ role: "assistant",
467
+ text: event.errorMessage ?? "An error occurred",
468
+ timestamp: Date.now(),
469
+ isError: true,
470
+ },
471
+ ],
472
+ };
473
+ default:
474
+ return state;
475
+ }
476
+ }
477
+
478
+ /**
479
+ * May a `final` fold into this message?
480
+ *
481
+ * Only when it is an assistant bubble from the SAME run. Two assistant
482
+ * messages in one turn are one answer with an interruption in the middle, and
483
+ * folding them is right. Two assistant messages from different runs are two
484
+ * answers to two questions, and folding them rewrote the earlier answer with
485
+ * the later one — visibly, in a transcript the user had already read.
486
+ *
487
+ * A message with no `runId` is history, which never folds either.
488
+ */
489
+ function foldsInto(
490
+ last: AgentChatMessage | undefined,
491
+ runId: string | null,
492
+ ): last is AgentChatMessage {
493
+ return (
494
+ last !== undefined &&
495
+ last.role === "assistant" &&
496
+ !last.isError &&
497
+ runId !== null &&
498
+ last.runId === runId
499
+ );
500
+ }
501
+
502
+ function reduceTool(state: AgentChatState, event: AgentToolEvent): AgentChatState {
503
+ const id = event.data.id;
504
+ if (!id) {
505
+ return state;
506
+ }
507
+ const existing = state.tools[id];
508
+ const base: AgentToolEntry = existing ?? {
509
+ id,
510
+ name: event.data.name ?? "tool",
511
+ status: "running",
512
+ startedAt: event.data.startedAt ?? Date.now(),
513
+ };
514
+ let entry: AgentToolEntry;
515
+ switch (event.event) {
516
+ case "tool.start":
517
+ entry = { ...base, name: event.data.name ?? base.name, status: "running" };
518
+ break;
519
+ case "tool.args":
520
+ entry = { ...base, input: event.data.input };
521
+ break;
522
+ case "tool.progress":
523
+ entry = { ...base, output: event.data.output };
524
+ break;
525
+ case "tool.complete":
526
+ entry = {
527
+ ...base,
528
+ status: "complete",
529
+ output: event.data.output,
530
+ completedAt: event.data.completedAt ?? Date.now(),
531
+ };
532
+ break;
533
+ case "tool.error":
534
+ entry = {
535
+ ...base,
536
+ status: "error",
537
+ output: event.data.output,
538
+ completedAt: event.data.completedAt ?? Date.now(),
539
+ };
540
+ break;
541
+ default:
542
+ return state;
543
+ }
544
+ const stepped = reduceStep(state, event, id);
545
+ return {
546
+ ...state,
547
+ tools: { ...state.tools, [id]: entry },
548
+ toolOrder: state.toolOrder.includes(id) ? state.toolOrder : [...state.toolOrder, id],
549
+ steps: stepped.steps,
550
+ retiredSteps: stepped.retiredSteps,
551
+ };
552
+ }
553
+
554
+ /* ── steps ───────────────────────────────────────────────────────────────── */
555
+
556
+ /** The step list and the retired ids, which only ever move together. */
557
+ interface StepUpdate {
558
+ steps: AgentChatStep[];
559
+ retiredSteps: string[];
560
+ }
561
+
562
+ /**
563
+ * `TOOL_CALL_RESULT.metadata`, read defensively.
564
+ *
565
+ * `null` when nothing usable arrived, which a transport that predates the
566
+ * contract will produce. A step with no metadata at all is treated as a
567
+ * success by `tool.complete` and as a failure by `tool.error`, which is the
568
+ * most either event can honestly claim on its own.
569
+ *
570
+ * Note what is NOT here: any reading of `content`. The result text is the
571
+ * model's copy of the reply, in whatever shape the tool returned, and the
572
+ * client's previous attempt to find `ok: false` in it matched nothing real —
573
+ * a Cortena tool result is `{ content: [...], details }`, so the envelope it
574
+ * was looking for was two levels down when it existed at all.
575
+ */
576
+ export function readResultMetadata(raw: unknown): AgentToolResultMetadata | null {
577
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
578
+ return null;
579
+ }
580
+ const record = raw as Record<string, unknown>;
581
+ const meta = readMeta(record.meta);
582
+ return {
583
+ ...(typeof record.toolName === "string" ? { toolName: record.toolName } : {}),
584
+ isError: record.isError === true,
585
+ ...(meta ? { meta } : {}),
586
+ };
587
+ }
588
+
589
+ function readMeta(raw: unknown): AgentToolResultMeta | null {
590
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
591
+ return null;
592
+ }
593
+ const record = raw as Record<string, unknown>;
594
+ const meta: AgentToolResultMeta = {
595
+ ...(typeof record.status === "number" ? { status: record.status } : {}),
596
+ ...(typeof record.code === "string" ? { code: record.code } : {}),
597
+ ...(typeof record.message === "string" ? { message: record.message } : {}),
598
+ };
599
+ return Object.keys(meta).length > 0 ? meta : null;
600
+ }
601
+
602
+ /**
603
+ * Is this the broker asking for a yes rather than reporting a failure?
604
+ *
605
+ * The broker refuses a catalogued write with `409 confirmation_required` until
606
+ * the call carries `confirmed: true`. It is the safety rail working, and the
607
+ * step says so instead of showing the user a red cross for it.
608
+ *
609
+ * Keyed on the CODE, never on the status. `409` is also an ordinary conflict —
610
+ * a task already claimed, a version that moved under a write — and wording one
611
+ * of those as "Needs your confirmation" invites the user to retry something
612
+ * that will never work.
613
+ */
614
+ function isConfirmationRequired(meta: AgentToolResultMeta | undefined): boolean {
615
+ return meta?.code === "confirmation_required";
616
+ }
617
+
618
+ /** Longest failure message put on a step. */
619
+ const ERROR_LIMIT = 200;
620
+
621
+ /**
622
+ * The human part of a tool result, for a failure that carried no `meta`.
623
+ *
624
+ * A Cortena tool returns `{ content: [{ type: "text", text }], details }`,
625
+ * JSON-stringified by the mapper, so the text blocks are the message and the
626
+ * rest is machinery. Anything else — a plain string, a shape this does not
627
+ * recognise — is used as it stands. Bounded and sanitised either way.
628
+ */
629
+ function resultText(output: unknown): string {
630
+ const direct = typeof output === "string" ? output : "";
631
+ let value: unknown = output;
632
+ if (direct) {
633
+ try {
634
+ value = JSON.parse(direct);
635
+ } catch {
636
+ return truncate(direct, ERROR_LIMIT);
637
+ }
638
+ }
639
+ if (typeof value === "object" && value !== null) {
640
+ const content = (value as { content?: unknown }).content;
641
+ if (Array.isArray(content)) {
642
+ const text = content
643
+ .map((part) =>
644
+ typeof part === "object" &&
645
+ part !== null &&
646
+ typeof (part as { text?: unknown }).text === "string"
647
+ ? (part as { text: string }).text
648
+ : "",
649
+ )
650
+ .filter(Boolean)
651
+ .join(" ");
652
+ if (text) {
653
+ return truncate(text, ERROR_LIMIT);
654
+ }
655
+ }
656
+ }
657
+ return truncate(direct, ERROR_LIMIT);
658
+ }
659
+
660
+ /** What a failed step says: the server's message, or the result text. */
661
+ function errorMessageOf(meta: AgentToolResultMeta | undefined, output: unknown): string {
662
+ const fromMeta = meta?.message ? truncate(meta.message, ERROR_LIMIT) : "";
663
+ return fromMeta || resultText(output) || "The call failed.";
664
+ }
665
+
666
+ /** The call's input as an object, or nothing when it never parsed as one. */
667
+ function readArgs(input: unknown): Record<string, unknown> | undefined {
668
+ return typeof input === "object" && input !== null && !Array.isArray(input)
669
+ ? (input as Record<string, unknown>)
670
+ : undefined;
671
+ }
672
+
673
+ /**
674
+ * Close every step still running, however the run ended.
675
+ *
676
+ * A step's spinner is a promise that something is happening, and the transport
677
+ * does not keep it: a run that errors, is stopped, or simply finishes with a
678
+ * result event lost on the way leaves a line spinning for ever under an answer
679
+ * that has already arrived. Three outcomes, because they are three different
680
+ * facts about the user's work:
681
+ *
682
+ * - `failed` — the run itself broke. Red, with the reason.
683
+ * - `stopped` — the user pressed Stop. Neutral: nothing went wrong.
684
+ * - `finished` — a plain RUN_FINISHED. The run SUCCEEDED, so the calls it made
685
+ * succeeded too; the words they finished with are still true, and only the
686
+ * spinner changes.
687
+ * - `paused` — RUN_FINISHED with `outcome.type === "interrupt"`. The call is
688
+ * still open and the run will resume, so the step neither ticks nor spins:
689
+ * it waits, visibly, and the result closes it later.
690
+ * - `denied` — the user refused the approval the run paused on. Only PAUSED
691
+ * steps end, and they end `stopped`.
692
+ *
693
+ * `running` and `paused` are both OPEN, which is what the first three outcomes
694
+ * close: an abort or a run error while a step waits for an approval must not
695
+ * leave it waiting for ever.
696
+ */
697
+ type StepOutcome =
698
+ | { kind: "failed"; message: string }
699
+ | { kind: "stopped" }
700
+ | { kind: "finished" }
701
+ | { kind: "paused" }
702
+ | { kind: "denied" };
703
+
704
+ /** Is this step still open — spinning, or waiting on the user? */
705
+ function isOpenStep(step: AgentChatStep): boolean {
706
+ return step.status === "running" || step.status === "paused";
707
+ }
708
+
709
+ function endRunningSteps(steps: AgentChatStep[], outcome: StepOutcome): AgentChatStep[] {
710
+ const closes = (step: AgentChatStep) =>
711
+ outcome.kind === "paused"
712
+ ? step.status === "running"
713
+ : outcome.kind === "denied"
714
+ ? step.status === "paused"
715
+ : isOpenStep(step);
716
+ if (!steps.some(closes)) {
717
+ return steps;
718
+ }
719
+ const endedAt = Date.now();
720
+ return steps.map((step) => {
721
+ if (!closes(step)) {
722
+ return step;
723
+ }
724
+ if (outcome.kind === "paused") {
725
+ // No `endedAt`: nothing has ended. It is the absence of `running` that
726
+ // stops the strip's ticking counter.
727
+ return { ...step, status: "paused" as const };
728
+ }
729
+ if (outcome.kind === "failed") {
730
+ return { ...step, status: "error" as const, endedAt, errorMessage: outcome.message };
731
+ }
732
+ if (outcome.kind === "stopped" || outcome.kind === "denied") {
733
+ return { ...step, status: "stopped" as const, endedAt };
734
+ }
735
+ return { ...step, status: "done" as const, endedAt };
736
+ });
737
+ }
738
+
739
+ /**
740
+ * A step appended, with the list capped at `STEP_LIMIT`.
741
+ *
742
+ * A turn with more calls than this is a loop, not a plan, and an uncapped list
743
+ * grows without bound in memory and re-renders the whole strip on every event
744
+ * of it. The most recent are kept: they are what the user is waiting on, and
745
+ * the ids of the ones dropped are kept so a late result for one is recognised
746
+ * rather than mistaken for a call nobody announced.
747
+ */
748
+ function append(
749
+ steps: AgentChatStep[],
750
+ retiredSteps: string[],
751
+ step: AgentChatStep,
752
+ ): StepUpdate {
753
+ const next = [...steps, step];
754
+ if (next.length <= STEP_LIMIT) {
755
+ return { steps: next, retiredSteps };
756
+ }
757
+ const dropped = next.slice(0, next.length - STEP_LIMIT).map((s) => s.id);
758
+ return {
759
+ steps: next.slice(-STEP_LIMIT),
760
+ retiredSteps: [...retiredSteps, ...dropped].slice(-RETIRED_LIMIT),
761
+ };
762
+ }
763
+
764
+ /** Most dropped ids remembered. Enough for stragglers, not a transcript. */
765
+ const RETIRED_LIMIT = 100;
766
+
767
+ function replaceStep(
768
+ steps: AgentChatStep[],
769
+ index: number,
770
+ next: AgentChatStep,
771
+ ): AgentChatStep[] {
772
+ const copy = [...steps];
773
+ copy[index] = next;
774
+ return copy;
775
+ }
776
+
777
+ /**
778
+ * One tool event, folded into the step list.
779
+ *
780
+ * Start and end are paired by the AG-UI `toolCallId`, not by position: calls
781
+ * overlap, and a run that issues two searches at once ended the wrong one when
782
+ * the list was treated as a stack.
783
+ *
784
+ * The label is recomputed when the arguments land, because they land in a
785
+ * SECOND event. `TOOL_CALL_START` carries the name only, so a label fixed at
786
+ * start time is "Working: engram_search" for the whole of the call — the one
787
+ * line the wording map exists to avoid.
788
+ */
789
+ function reduceStep(
790
+ state: AgentChatState,
791
+ event: AgentToolEvent,
792
+ id: string,
793
+ ): StepUpdate {
794
+ const { steps, retiredSteps } = state;
795
+ const keep: StepUpdate = { steps, retiredSteps };
796
+ const index = steps.findIndex((step) => step.id === id);
797
+ const existing = index === -1 ? undefined : steps[index];
798
+
799
+ // A call the cap already dropped. Its result is arriving after fifty other
800
+ // calls pushed it off the front, and there is nothing left to update: the
801
+ // only alternatives are a stale line back at the bottom of the strip or
802
+ // silence, and silence about the fiftieth-oldest call of a runaway turn is
803
+ // the right answer.
804
+ if (!existing && retiredSteps.includes(id)) {
805
+ return keep;
806
+ }
807
+
808
+ if (event.event === "tool.start") {
809
+ // A start for a call that has already ENDED is a replay, not a restart.
810
+ // A reconnect re-streams the run from the top, so every call of it starts
811
+ // again; taking that at face value put a finished run's steps back into
812
+ // spinners, with no second result coming to take them out again.
813
+ if (existing && !isOpenStep(existing)) {
814
+ return keep;
815
+ }
816
+ const toolName = event.data.name ?? existing?.toolName ?? "tool";
817
+ const step: AgentChatStep = {
818
+ id,
819
+ toolName,
820
+ ...(existing?.args ? { args: existing.args } : {}),
821
+ label: stepLabel(toolName, existing?.args, "running"),
822
+ startedAt: event.data.startedAt ?? existing?.startedAt ?? Date.now(),
823
+ status: "running",
824
+ };
825
+ return existing && index !== -1
826
+ ? { steps: replaceStep(steps, index, step), retiredSteps }
827
+ : append(steps, retiredSteps, step);
828
+ }
829
+
830
+ // Anything else about a call nobody announced. The strip still shows it —
831
+ // a result with no start is a call that happened, and silence about it is
832
+ // worse than a line with a generic label.
833
+ const base: AgentChatStep = existing ?? {
834
+ id,
835
+ toolName: event.data.name ?? "tool",
836
+ label: stepLabel(event.data.name ?? "tool", undefined, "running"),
837
+ startedAt: event.data.startedAt ?? Date.now(),
838
+ status: "running",
839
+ };
840
+ const at = index === -1 ? steps.length : index;
841
+ const put = (next: AgentChatStep): StepUpdate =>
842
+ index === -1
843
+ ? append(steps, retiredSteps, next)
844
+ : { steps: replaceStep(steps, at, next), retiredSteps };
845
+
846
+ switch (event.event) {
847
+ case "tool.args": {
848
+ const args = readArgs(event.data.input);
849
+ if (!args) {
850
+ return keep;
851
+ }
852
+ // The broker asked for a confirmation before the arguments landed, so
853
+ // the question it asked is "that action". It has a name now, and the
854
+ // card the user is reading is the one place the name matters.
855
+ if (base.label === CONFIRMATION_FALLBACK) {
856
+ return put({ ...base, args, label: confirmationRequiredLabel(args) });
857
+ }
858
+ // A finished step keeps the words it finished with — unless those words
859
+ // are the fallback. Arguments can land after the result on a short call,
860
+ // and "Working: broker_invoke", ticked, is the one line the wording map
861
+ // exists to avoid; a real sentence is better late than never. A label
862
+ // the arguments already produced is never rewritten.
863
+ const open = isOpenStep(base);
864
+ const relabel = open || isGenericLabel(base.toolName, base.label);
865
+ return put({
866
+ ...base,
867
+ args,
868
+ label: relabel ? stepLabel(base.toolName, args, open ? "running" : "done") : base.label,
869
+ });
870
+ }
871
+ case "tool.progress":
872
+ return keep;
873
+ case "tool.complete":
874
+ case "tool.error":
875
+ return put(endStep(base, event));
876
+ default:
877
+ return keep;
878
+ }
879
+ }
880
+
881
+ /** The confirmation label with no operation on it; see `tool.args` above. */
882
+ const CONFIRMATION_FALLBACK = confirmationRequiredLabel(undefined);
883
+
884
+ /**
885
+ * A start event's step, ended by its result.
886
+ *
887
+ * The whole outcome decision, in one place, against
888
+ * `TOOL_CALL_RESULT.metadata` and nothing else:
889
+ *
890
+ * - `isError` with `meta.code === "confirmation_required"` — not a failure.
891
+ * The broker is asking a question, and the step is relabelled to ask it.
892
+ * - `isError` otherwise — a failure, worded from `meta.message` when there is
893
+ * one and from the result text when there is not.
894
+ * - neither — done.
895
+ */
896
+ function endStep(base: AgentChatStep, event: AgentToolEvent): AgentChatStep {
897
+ const metadata: AgentToolResultMetadata | null = readResultMetadata(event.data.metadata);
898
+ // Either witness is enough. `tool.error` is what the bridge emits when the
899
+ // metadata says `isError`, but it is also what a transport that predates the
900
+ // contract emits with no metadata at all — and a metadata object that
901
+ // arrived without an `isError` field then made `isError` false and drew a
902
+ // failed call as a success.
903
+ const isError = metadata?.isError === true || event.event === "tool.error";
904
+ const meta: AgentToolResultMeta | undefined = metadata?.meta;
905
+ const endedAt = event.data.completedAt ?? Date.now();
906
+
907
+ if (isError && isConfirmationRequired(meta)) {
908
+ return { ...base, status: "done", endedAt, label: confirmationRequiredLabel(base.args) };
909
+ }
910
+ if (isError) {
911
+ return { ...base, status: "error", endedAt, errorMessage: errorMessageOf(meta, event.data.output) };
912
+ }
913
+ return { ...base, status: "done", endedAt, label: stepLabel(base.toolName, base.args, "done") };
914
+ }
915
+
916
+ /* ── the hook ────────────────────────────────────────────────────────────── */
917
+
918
+ /** How long a burst of step changes is collapsed for before it is written. */
919
+ const STEP_WRITE_DEBOUNCE_MS = 250;
920
+
921
+ export interface UseAgentChatOptions {
922
+ client: AgentChatClient;
923
+ /**
924
+ * Names the `sessionStorage` slot the current session key lives in. Two
925
+ * pop-ups with different scopes never share a key, which is what keeps one
926
+ * extension's chat out of another's.
927
+ */
928
+ storageScope: string;
929
+ /** Skip reading and writing `sessionStorage`; for a transient mount. */
930
+ ephemeral?: boolean;
931
+ thinking?: "low" | "medium" | "high";
932
+ }
933
+
934
+ export interface UseAgentChatResult extends AgentChatState {
935
+ send: (text: string, attachments?: unknown[]) => void;
936
+ sendAction: (action: NonNullable<Parameters<AgentChatClient["send"]>[0]["a2uiAction"]>) => void;
937
+ abort: () => void;
938
+ resolveApproval: (id: string, decision: AgentApprovalDecision) => void;
939
+ newSession: () => void;
940
+ resumeSession: (key: string) => void;
941
+ loadOlder: () => void;
942
+ refreshSessions: () => void;
943
+ }
944
+
945
+ export function useAgentChat({
946
+ client,
947
+ storageScope,
948
+ ephemeral,
949
+ thinking,
950
+ }: UseAgentChatOptions): UseAgentChatResult {
951
+ const [state, dispatch] = React.useReducer(reduce, EMPTY);
952
+ const stateRef = React.useRef(state);
953
+ stateRef.current = state;
954
+
955
+ // Subscribe once per client. A collapsed pop-up keeps this mounted — the
956
+ // panel is hidden, not unmounted — so a run started before the collapse
957
+ // keeps streaming into the state the pill's unread dot is read from.
958
+ React.useEffect(() => {
959
+ const offChat = client.onChat((event) => dispatch({ kind: "chat", event }));
960
+ const offTool = client.onTool((event) => dispatch({ kind: "tool", event }));
961
+ const offApproval = client.onApproval((event) => {
962
+ if (event.type === "request") {
963
+ dispatch({ kind: "approval/request", request: event.request });
964
+ } else {
965
+ dispatch({ kind: "approval/resolved", id: event.id });
966
+ }
967
+ });
968
+ return () => {
969
+ offChat();
970
+ offTool();
971
+ offApproval();
972
+ };
973
+ }, [client]);
974
+
975
+ const refreshSessions = React.useCallback(() => {
976
+ dispatch({ kind: "sessions/loading" });
977
+ void client
978
+ .request<AgentSessionsListResponse>("sessions.list", { includeDerivedTitles: true })
979
+ .then((response) => {
980
+ // Filtered to this agent: a shared control plane lists every session on
981
+ // the pod, and an extension must never show another agent's chats.
982
+ const sessions = (response?.sessions ?? []).filter((s) =>
983
+ isSessionOfAgent(s.key, client.agentId),
984
+ );
985
+ dispatch({ kind: "sessions/loaded", sessions });
986
+ })
987
+ .catch((err: unknown) => {
988
+ dispatch({ kind: "sessions/loaded", sessions: [] });
989
+ dispatch({ kind: "error", message: errorText(err) });
990
+ });
991
+ }, [client]);
992
+
993
+ const loadHistory = React.useCallback(
994
+ (sessionKey: string) => {
995
+ dispatch({ kind: "history/loading" });
996
+ void client
997
+ .request<AgentChatHistoryResponse>("chat.history", { sessionKey, limit: 100 })
998
+ .then((response) => {
999
+ const records = response?.messages ?? [];
1000
+ dispatch({
1001
+ kind: "history/loaded",
1002
+ sessionKey,
1003
+ messages: parseHistoryMessages(records),
1004
+ hasMore: response?.hasMore ?? false,
1005
+ records: records.length,
1006
+ });
1007
+ })
1008
+ .catch(() => dispatch({ kind: "history/failed" }));
1009
+ },
1010
+ [client],
1011
+ );
1012
+
1013
+ // Restore this window's session, or mint one. `sessionStorage`, not
1014
+ // `localStorage`: a second window must not adopt the first window's session.
1015
+ const restored = React.useRef(false);
1016
+ React.useEffect(() => {
1017
+ if (restored.current) {
1018
+ return;
1019
+ }
1020
+ restored.current = true;
1021
+ const stored = ephemeral ? null : readStoredSessionKey(storageScope);
1022
+ const key = stored && isSessionOfAgent(stored, client.agentId) ? stored : null;
1023
+ if (key) {
1024
+ dispatch({ kind: "session", key });
1025
+ // Before the history load, and after the `session` reset it would
1026
+ // otherwise be cleared by. `chat.history` has no steps in it — the
1027
+ // server never sees them — so this slot is the only way back.
1028
+ const steps = ephemeral ? [] : readStoredSteps(storageScope, key);
1029
+ if (steps.length > 0) {
1030
+ dispatch({ kind: "steps/restored", steps });
1031
+ }
1032
+ loadHistory(key);
1033
+ }
1034
+ refreshSessions();
1035
+ }, [client, ephemeral, loadHistory, refreshSessions, storageScope]);
1036
+
1037
+ /**
1038
+ * Keep the per-window store in step with the strip, on a debounce.
1039
+ *
1040
+ * Skipped while there is no session key, which is also the first commit —
1041
+ * writing then would empty the slot the effect above is about to read.
1042
+ *
1043
+ * Debounced because a busy run changes `steps` several times per call —
1044
+ * start, arguments, result — and each write serialises the whole list and
1045
+ * hands it to a SYNCHRONOUS storage API on the main thread. A quarter of a
1046
+ * second collapses a call's worth of edits into one write and is far below
1047
+ * anything a reload could notice.
1048
+ */
1049
+ const pendingSteps = React.useRef<{ sessionKey: string; steps: AgentChatStep[] } | null>(null);
1050
+ React.useEffect(() => {
1051
+ if (ephemeral || !state.sessionKey) {
1052
+ return;
1053
+ }
1054
+ const sessionKey = state.sessionKey;
1055
+ const steps = state.steps;
1056
+ pendingSteps.current = { sessionKey, steps };
1057
+ const id = setTimeout(() => {
1058
+ pendingSteps.current = null;
1059
+ writeStoredSteps(storageScope, sessionKey, steps);
1060
+ }, STEP_WRITE_DEBOUNCE_MS);
1061
+ return () => clearTimeout(id);
1062
+ }, [ephemeral, state.sessionKey, state.steps, storageScope]);
1063
+
1064
+ // Flushed on the way out. The unmount is the case the slot exists for — a
1065
+ // pop-up collapsing, a host remounting the surface mid-run — and a debounce
1066
+ // that dropped its last write on unmount would lose exactly the strip it was
1067
+ // meant to preserve.
1068
+ React.useEffect(() => {
1069
+ return () => {
1070
+ const pending = pendingSteps.current;
1071
+ if (!ephemeral && pending) {
1072
+ pendingSteps.current = null;
1073
+ writeStoredSteps(storageScope, pending.sessionKey, pending.steps);
1074
+ }
1075
+ };
1076
+ }, [ephemeral, storageScope]);
1077
+
1078
+ const setSession = React.useCallback(
1079
+ (key: string) => {
1080
+ dispatch({ kind: "session", key });
1081
+ if (!ephemeral) {
1082
+ writeStoredSessionKey(storageScope, key);
1083
+ }
1084
+ },
1085
+ [ephemeral, storageScope],
1086
+ );
1087
+
1088
+ const run = React.useCallback(
1089
+ (text: string, extra: Partial<Parameters<AgentChatClient["send"]>[0]> = {}) => {
1090
+ const current = stateRef.current;
1091
+ // One run at a time. A second `send` on a streaming session opens a
1092
+ // second stream into one store: two sets of deltas overwrite each
1093
+ // other's snapshot, `final` folds one run's answer into the other's
1094
+ // bubble, and `abort` can only reach whichever the client thinks is
1095
+ // active. The composer disables Send while a run is live; this is the
1096
+ // same rule for Enter, for a2ui actions and for a caller of the hook.
1097
+ if (current.streaming) {
1098
+ return;
1099
+ }
1100
+ const sessionKey = current.sessionKey ?? mintSessionKey(client.agentId);
1101
+ if (!current.sessionKey && !ephemeral) {
1102
+ writeStoredSessionKey(storageScope, sessionKey);
1103
+ }
1104
+ const runId = generateId();
1105
+ // An action with no typed text is a click, not a sentence: a user bubble
1106
+ // for it would put `{"name":"createTask"}` in the transcript as though
1107
+ // the user had said it.
1108
+ const message: AgentChatMessage | null =
1109
+ text || (!extra.a2uiAction && !extra.mcpAppAction)
1110
+ ? { id: runId, role: "user", text, timestamp: Date.now(), runId }
1111
+ : null;
1112
+ dispatch({ kind: "send", message, runId, sessionKey });
1113
+ void client
1114
+ .send({
1115
+ sessionKey,
1116
+ message: text,
1117
+ runId,
1118
+ ...(thinking ? { thinking } : {}),
1119
+ ...extra,
1120
+ })
1121
+ .then(() => refreshSessions())
1122
+ .catch((err: unknown) => dispatch({ kind: "send/failed", message: errorText(err) }));
1123
+ },
1124
+ [client, ephemeral, refreshSessions, storageScope, thinking],
1125
+ );
1126
+
1127
+ const send = React.useCallback(
1128
+ (text: string, attachments?: unknown[]) => {
1129
+ run(text, attachments?.length ? { attachments } : {});
1130
+ },
1131
+ [run],
1132
+ );
1133
+
1134
+ const sendAction = React.useCallback(
1135
+ (a2uiAction: NonNullable<Parameters<AgentChatClient["send"]>[0]["a2uiAction"]>) => {
1136
+ run("", { a2uiAction });
1137
+ },
1138
+ [run],
1139
+ );
1140
+
1141
+ const abort = React.useCallback(() => {
1142
+ const { runId, sessionKey } = stateRef.current;
1143
+ dispatch({ kind: "abort" });
1144
+ void client
1145
+ .abort({
1146
+ ...(runId ? { runId } : {}),
1147
+ ...(sessionKey ? { sessionKey } : {}),
1148
+ })
1149
+ .catch(() => {
1150
+ // Best-effort: local state is already reset, and dropping the stream
1151
+ // aborts the run server-side anyway.
1152
+ });
1153
+ }, [client]);
1154
+
1155
+ const resolveApproval = React.useCallback(
1156
+ (id: string, decision: AgentApprovalDecision) => {
1157
+ const request = stateRef.current.approvals.find((r) => r.id === id);
1158
+ if (!request) {
1159
+ return;
1160
+ }
1161
+ // A deny also ends the step the run paused on: the call is not going to
1162
+ // happen, and a line left waiting for an approval the user has already
1163
+ // refused is the state this whole path exists to avoid.
1164
+ dispatch(
1165
+ decision === "deny" ? { kind: "approval/denied", id } : { kind: "approval/resolved", id },
1166
+ );
1167
+ // Bound to the run and the session the REQUEST arrived on, never to
1168
+ // whatever is in flight now. The two come apart the moment a user opens
1169
+ // another session while a command waits for an answer.
1170
+ void client
1171
+ .resolveApproval(id, decision, {
1172
+ runId: request.runId,
1173
+ sessionKey: request.sessionKey,
1174
+ })
1175
+ .catch((err: unknown) => {
1176
+ dispatch({ kind: "approval/failed", request, message: errorText(err) });
1177
+ });
1178
+ },
1179
+ [client],
1180
+ );
1181
+
1182
+ const newSession = React.useCallback(() => {
1183
+ setSession(mintSessionKey(client.agentId));
1184
+ }, [client, setSession]);
1185
+
1186
+ const resumeSession = React.useCallback(
1187
+ (key: string) => {
1188
+ setSession(key);
1189
+ loadHistory(key);
1190
+ },
1191
+ [loadHistory, setSession],
1192
+ );
1193
+
1194
+ const loadOlder = React.useCallback(() => {
1195
+ const current = stateRef.current;
1196
+ if (!current.sessionKey || current.loadingHistory || !current.hasMoreHistory) {
1197
+ return;
1198
+ }
1199
+ dispatch({ kind: "history/loading" });
1200
+ void client
1201
+ .request<AgentChatHistoryResponse>("chat.history", {
1202
+ sessionKey: current.sessionKey,
1203
+ limit: 100,
1204
+ // Records, not bubbles. See `historyCount`.
1205
+ offset: current.historyCount,
1206
+ })
1207
+ .then((response) => {
1208
+ const records = response?.messages ?? [];
1209
+ dispatch({
1210
+ kind: "history/older",
1211
+ messages: parseHistoryMessages(records),
1212
+ hasMore: response?.hasMore ?? false,
1213
+ records: records.length,
1214
+ });
1215
+ })
1216
+ .catch(() => dispatch({ kind: "history/failed" }));
1217
+ }, [client]);
1218
+
1219
+ return {
1220
+ ...state,
1221
+ send,
1222
+ sendAction,
1223
+ abort,
1224
+ resolveApproval,
1225
+ newSession,
1226
+ resumeSession,
1227
+ loadOlder,
1228
+ refreshSessions,
1229
+ };
1230
+ }
1231
+
1232
+ function errorText(err: unknown): string {
1233
+ return err instanceof Error ? err.message : "The agent could not be reached";
1234
+ }