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,549 @@
1
+ /**
2
+ * Sessions, history and the filter chain.
3
+ *
4
+ * Ported from `cortena-shared/src/stores/use-chat.ts` and
5
+ * `cortenaweb/components/chat/message-bubble.tsx` (Cortena monorepo).
6
+ * `cortena-shared` is not published, so the rules the two transports share
7
+ * live here; keep them in step.
8
+ *
9
+ * The one deliberate difference is where the current session key is kept.
10
+ * cortenaweb puts it in `localStorage`, which is shared by every tab of an
11
+ * origin, so a second window adopted the first window's session: it inherited
12
+ * a stream that was not its own and both windows then showed the same content.
13
+ * Sessions here are **per window** — the key lives in `sessionStorage`, under a
14
+ * name scoped to the extension — so opening the extension twice starts two
15
+ * sessions. Both stay listed and either window can resume either one; two
16
+ * windows on one session may both send, and cortenacore serialises the runs.
17
+ */
18
+
19
+ import { truncate } from "./step-label";
20
+ import type { AgentChatMessage, AgentChatStep } from "./types";
21
+
22
+ /* ── minting and remembering a key ───────────────────────────────────────── */
23
+
24
+ /**
25
+ * A new session key, minted client-side.
26
+ *
27
+ * `agent:<agentId>:new-<Date.now()>-<random36>`. cortenacore reads the
28
+ * `agent:<id>:` prefix to bind the session to that agent's template, and the
29
+ * key is the AG-UI `threadId` verbatim, prefix included. Minting it rather than
30
+ * asking for one is what lets the first message go out immediately.
31
+ */
32
+ export function mintSessionKey(agentId: string): string {
33
+ return `agent:${agentId}:new-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
34
+ }
35
+
36
+ /** Was this key minted locally for a chat that had no session yet? */
37
+ export function isProvisionalSessionKey(key: string): boolean {
38
+ return /(^|:)new-\d+-[a-z0-9]+$/.test(key);
39
+ }
40
+
41
+ /** Does this session belong to this agent? `sessions.list` is filtered by it. */
42
+ export function isSessionOfAgent(key: string, agentId: string): boolean {
43
+ return key.startsWith(`agent:${agentId}:`);
44
+ }
45
+
46
+ /** The `sessionStorage` name the current key is kept under, per extension. */
47
+ export function sessionStorageKey(scope: string): string {
48
+ return `cortena.agent-chat.${scope}.session`;
49
+ }
50
+
51
+ /** The `sessionStorage` name the open/collapsed state is kept under. */
52
+ export function viewStorageKey(scope: string): string {
53
+ return `cortena.agent-chat.${scope}.view`;
54
+ }
55
+
56
+ /** The `sessionStorage` name this window's progress steps are kept under. */
57
+ export function stepsStorageKey(scope: string): string {
58
+ return `cortena.agent-chat.${scope}.steps`;
59
+ }
60
+
61
+ /**
62
+ * `sessionStorage`, or nothing.
63
+ *
64
+ * Absent during SSR and refused outright when a browser blocks storage for the
65
+ * origin, and neither is a reason for the chat not to work: without it a window
66
+ * simply forgets its session on reload.
67
+ */
68
+ export function windowSessionStorage(): Storage | null {
69
+ try {
70
+ return typeof window === "undefined" ? null : window.sessionStorage;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ export function readStoredSessionKey(scope: string): string | null {
77
+ try {
78
+ return windowSessionStorage()?.getItem(sessionStorageKey(scope)) ?? null;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ export function writeStoredSessionKey(scope: string, key: string | null): void {
85
+ try {
86
+ const storage = windowSessionStorage();
87
+ if (!storage) {
88
+ return;
89
+ }
90
+ if (key === null) {
91
+ storage.removeItem(sessionStorageKey(scope));
92
+ } else {
93
+ storage.setItem(sessionStorageKey(scope), key);
94
+ }
95
+ } catch {
96
+ // A blocked storage is not a reason to lose the run in flight.
97
+ }
98
+ }
99
+
100
+ /* ── the progress steps of the turn ──────────────────────────────────────── */
101
+
102
+ /** Most steps kept, in memory and in `sessionStorage`. A turn that long is a runaway loop. */
103
+ export const STEP_LIMIT = 50;
104
+
105
+ /**
106
+ * Most bytes the steps slot may occupy.
107
+ *
108
+ * `sessionStorage` is a few megabytes for the whole origin, shared with
109
+ * everything else the host keeps there, and a step count alone does not bound
110
+ * the size: fifty steps whose labels are all near the limit, with a failure
111
+ * message on each, is a slot nobody budgeted for. Over the cap the OLDEST are
112
+ * dropped, one at a time, because the recent ones are the ones a reload has to
113
+ * bring back.
114
+ */
115
+ const STEP_BYTE_LIMIT = 64 * 1024;
116
+
117
+ /** Longest tool call id or tool name kept, in storage and on `data-tool`. */
118
+ export const STORED_TEXT_LIMIT = 80;
119
+
120
+ /**
121
+ * The steps of the current turn, and the session they belong to.
122
+ *
123
+ * They go in the per-window store beside the session key, for the same reason
124
+ * the key does: a tab is reloaded mid-run, or a host remounts the surface on a
125
+ * route change, and both threw the whole strip away. (Collapsing the pop-up is
126
+ * NOT one of them — the panel is `hidden`, not unmounted, and the run streams
127
+ * into it either way.) Messages come back from `chat.history`; steps do not
128
+ * exist on the server, so this is the only place they can come back from.
129
+ *
130
+ * Scoped to a session key, and checked on read: resuming a different session
131
+ * must not inherit the last one's steps.
132
+ */
133
+ export function readStoredSteps(scope: string, sessionKey: string): AgentChatStep[] {
134
+ try {
135
+ const raw = windowSessionStorage()?.getItem(stepsStorageKey(scope));
136
+ if (!raw) {
137
+ return [];
138
+ }
139
+ const parsed = JSON.parse(raw) as { sessionKey?: unknown; steps?: unknown };
140
+ if (parsed?.sessionKey !== sessionKey || !Array.isArray(parsed.steps)) {
141
+ return [];
142
+ }
143
+ return (parsed.steps as AgentChatStep[])
144
+ .filter((step) => typeof step?.id === "string" && typeof step?.label === "string")
145
+ .map(restoreStep)
146
+ .slice(-STEP_LIMIT);
147
+ } catch {
148
+ // Storage refused, or somebody else wrote the slot. Neither is worth a
149
+ // broken chat; the strip simply starts empty.
150
+ return [];
151
+ }
152
+ }
153
+
154
+ /** Every status a step may legitimately come back with. */
155
+ const STEP_STATUSES: ReadonlySet<string> = new Set([
156
+ "running",
157
+ "done",
158
+ "error",
159
+ "stopped",
160
+ "paused",
161
+ ]);
162
+
163
+ /**
164
+ * A step as it comes back from storage.
165
+ *
166
+ * The important cases are `running` and `paused`. Neither is true any more:
167
+ * the run that owned the call ended when the surface went away — a reload, a
168
+ * remount on a route change; a collapsed pop-up only HIDES the strip and never
169
+ * reaches this path — and no result will arrive for it now, so a restored
170
+ * spinner spins until the user gives up and reloads again, which restores it.
171
+ *
172
+ * It comes back `stopped`, with no message. Neutral is the honest reading:
173
+ * nothing failed, the work simply did not continue, and "Interrupted." drawn in
174
+ * the failure colour told the user their call had broken when the page had.
175
+ *
176
+ * An unrecognised status is treated the same way. Nothing else writes this
177
+ * slot today, but it is per-origin `sessionStorage` and this is the only place
178
+ * that decides what a strip is allowed to render.
179
+ */
180
+ function restoreStep(step: AgentChatStep): AgentChatStep {
181
+ const known = STEP_STATUSES.has(step.status);
182
+ if (known && step.status !== "running" && step.status !== "paused") {
183
+ return step;
184
+ }
185
+ const restored: AgentChatStep = {
186
+ ...step,
187
+ status: "stopped",
188
+ endedAt: step.endedAt ?? Date.now(),
189
+ };
190
+ delete restored.errorMessage;
191
+ return restored;
192
+ }
193
+
194
+ /**
195
+ * The fields of a step that are safe to keep, and the only ones kept.
196
+ *
197
+ * `args` is deliberately absent. A tool call's arguments are the user's data —
198
+ * what they searched for, whose record they opened, the body of what they
199
+ * wrote — and `sessionStorage` is readable by every script on the origin,
200
+ * survives the run, and is the sort of thing that ends up in a support bundle.
201
+ * Nothing on screen needs them after the run: the LABEL is already derived
202
+ * from them, and the label is what a restored strip shows.
203
+ */
204
+ function storedStep(step: AgentChatStep): AgentChatStep {
205
+ return {
206
+ // Bounded, both of them. Neither is written by this package: the id is the
207
+ // producer's `toolCallId` and the name is whatever the model called, so a
208
+ // single call carrying 9 kB of either filled the byte cap on its own and
209
+ // pushed every real step out of the slot.
210
+ id: truncate(step.id, STORED_TEXT_LIMIT),
211
+ toolName: truncate(step.toolName, STORED_TEXT_LIMIT),
212
+ label: step.label,
213
+ status: step.status,
214
+ startedAt: step.startedAt,
215
+ ...(step.endedAt === undefined ? {} : { endedAt: step.endedAt }),
216
+ ...(step.errorMessage === undefined ? {} : { errorMessage: step.errorMessage }),
217
+ };
218
+ }
219
+
220
+ /** The slot's contents, projected and trimmed to fit the byte cap. */
221
+ export function serialiseSteps(sessionKey: string, steps: readonly AgentChatStep[]): string {
222
+ let kept = steps.slice(-STEP_LIMIT).map(storedStep);
223
+ let json = JSON.stringify({ sessionKey, steps: kept });
224
+ while (kept.length > 1 && json.length > STEP_BYTE_LIMIT) {
225
+ kept = kept.slice(1);
226
+ json = JSON.stringify({ sessionKey, steps: kept });
227
+ }
228
+ return json;
229
+ }
230
+
231
+ export function writeStoredSteps(
232
+ scope: string,
233
+ sessionKey: string | null,
234
+ steps: readonly AgentChatStep[],
235
+ ): void {
236
+ try {
237
+ const storage = windowSessionStorage();
238
+ if (!storage) {
239
+ return;
240
+ }
241
+ if (!sessionKey || steps.length === 0) {
242
+ storage.removeItem(stepsStorageKey(scope));
243
+ return;
244
+ }
245
+ storage.setItem(stepsStorageKey(scope), serialiseSteps(sessionKey, steps));
246
+ } catch {
247
+ // Blocked, or over quota. The run in flight is not worth losing over it.
248
+ }
249
+ }
250
+
251
+ /* ── history ─────────────────────────────────────────────────────────────── */
252
+
253
+ function generateId(): string {
254
+ return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
255
+ }
256
+
257
+ function isToolRelatedMessage(msg: Record<string, unknown>): boolean {
258
+ const role = typeof msg.role === "string" ? msg.role.toLowerCase() : "";
259
+ if (role === "tool" || role === "toolresult" || role === "tool_result") {
260
+ return true;
261
+ }
262
+ const content = msg.content as Array<{ type: string }> | undefined;
263
+ if (Array.isArray(content) && content.length > 0) {
264
+ const toolTypes = new Set([
265
+ "tool_use",
266
+ "tool_call",
267
+ "tool_result",
268
+ "toolresult",
269
+ "server_tool_use",
270
+ ]);
271
+ const hasText = content.some((c) => c.type === "text" || c.type === "thinking");
272
+ const hasToolBlocks = content.some((c) => toolTypes.has(c.type));
273
+ if (hasToolBlocks && !hasText) {
274
+ return true;
275
+ }
276
+ }
277
+ return false;
278
+ }
279
+
280
+ /**
281
+ * Strip `System: [timestamp] …` lines and `[System Message]` prefixes the
282
+ * runtime prepends to a user turn as agent context. They are not user text.
283
+ */
284
+ function stripSystemPrefixes(text: string): string {
285
+ return text
286
+ .split("\n")
287
+ .filter((line) => !/^System:\s*\[/.test(line) && !line.startsWith("[System Message]"))
288
+ .join("\n")
289
+ .replace(/^\n+/, "");
290
+ }
291
+
292
+ /** Lift `<thinking>…</thinking>` out of plain text. */
293
+ export function separateThinking(text: string): { thinkingText?: string; mainText: string } {
294
+ const match = text.match(/<thinking>([\s\S]*?)<\/thinking>/);
295
+ if (!match?.[1]) {
296
+ return { mainText: text };
297
+ }
298
+ const thinkingText = match[1].trim();
299
+ const mainText = text.replace(/<thinking>[\s\S]*?<\/thinking>\s*/, "").trim();
300
+ return { ...(thinkingText ? { thinkingText } : {}), mainText };
301
+ }
302
+
303
+ function parseOneHistoryMessage(raw: unknown): AgentChatMessage {
304
+ const msg = raw as Record<string, unknown>;
305
+ const content = msg.content as
306
+ | Array<{ type: string; text?: string; thinking?: string; a2ui?: unknown }>
307
+ | undefined;
308
+ let text = Array.isArray(content)
309
+ ? content
310
+ .filter((c) => c.type === "text" && c.text)
311
+ .map((c) => c.text!)
312
+ .join("")
313
+ : typeof msg.content === "string"
314
+ ? msg.content
315
+ : typeof msg.text === "string"
316
+ ? msg.text
317
+ : "";
318
+
319
+ const isToolMessage = isToolRelatedMessage(msg);
320
+ const role = ((msg.role as string) === "user" ? "user" : "assistant") as "user" | "assistant";
321
+ if (role === "user") {
322
+ text = stripSystemPrefixes(text);
323
+ }
324
+
325
+ let thinkingText = "";
326
+ if (typeof msg.thinking === "string" && msg.thinking.trim()) {
327
+ thinkingText = msg.thinking.trim();
328
+ } else if (Array.isArray(content)) {
329
+ thinkingText = content
330
+ .filter((c) => c.type === "thinking")
331
+ .map((c) => c.thinking ?? c.text ?? "")
332
+ .filter(Boolean)
333
+ .join("\n\n");
334
+ }
335
+ if (!thinkingText) {
336
+ const { thinkingText: tagThinking, mainText } = separateThinking(text);
337
+ if (tagThinking) {
338
+ return {
339
+ id: typeof msg.id === "string" ? msg.id : generateId(),
340
+ role,
341
+ text: mainText,
342
+ timestamp: (msg.timestamp as number) ?? Date.now(),
343
+ thinkingText: tagThinking,
344
+ ...(isToolMessage ? { isToolMessage } : {}),
345
+ };
346
+ }
347
+ }
348
+
349
+ return {
350
+ id: typeof msg.id === "string" ? msg.id : generateId(),
351
+ role,
352
+ text,
353
+ timestamp: (msg.timestamp as number) ?? Date.now(),
354
+ ...(thinkingText ? { thinkingText } : {}),
355
+ ...(isToolMessage ? { isToolMessage } : {}),
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Merge an agent turn — assistant messages plus tool call and result messages —
361
+ * into one visible message. Everything except the final substantive response is
362
+ * folded into the chain of thought. Only a genuine user message breaks the turn.
363
+ */
364
+ function mergeConsecutiveAssistantMessages(messages: AgentChatMessage[]): AgentChatMessage[] {
365
+ const result: AgentChatMessage[] = [];
366
+ let run: AgentChatMessage[] = [];
367
+
368
+ const flush = () => {
369
+ const first = run[0];
370
+ if (!first) {
371
+ return;
372
+ }
373
+ if (run.length === 1) {
374
+ result.push(first);
375
+ run = [];
376
+ return;
377
+ }
378
+ let visibleIdx = run.length - 1;
379
+ while (visibleIdx > 0 && !(run[visibleIdx]?.text ?? "").trim()) {
380
+ visibleIdx--;
381
+ }
382
+ const visible = run[visibleIdx] ?? first;
383
+ const cot: string[] = [];
384
+ for (let i = 0; i < run.length; i++) {
385
+ const entry = run[i];
386
+ if (!entry) {
387
+ continue;
388
+ }
389
+ if (i === visibleIdx) {
390
+ if (visible.thinkingText) {
391
+ cot.push(visible.thinkingText);
392
+ }
393
+ continue;
394
+ }
395
+ if (entry.thinkingText) {
396
+ cot.push(entry.thinkingText);
397
+ }
398
+ if (entry.text.trim()) {
399
+ cot.push(entry.text.trim());
400
+ }
401
+ }
402
+ const combined = cot.filter(Boolean).join("\n\n");
403
+ result.push({ ...visible, ...(combined ? { thinkingText: combined } : {}) });
404
+ run = [];
405
+ };
406
+
407
+ for (const msg of messages) {
408
+ const isRealUser = msg.role === "user" && !msg.isToolMessage && msg.text.trim().length > 0;
409
+ if (isRealUser) {
410
+ flush();
411
+ result.push(msg);
412
+ } else {
413
+ run.push(msg);
414
+ }
415
+ }
416
+ flush();
417
+ return result;
418
+ }
419
+
420
+ /** Raw transcript records to the messages the list renders. */
421
+ export function parseHistoryMessages(raw: readonly unknown[]): AgentChatMessage[] {
422
+ return mergeConsecutiveAssistantMessages(raw.map(parseOneHistoryMessage));
423
+ }
424
+
425
+ /**
426
+ * The history shrink guard.
427
+ *
428
+ * A history load must not shrink the session already on screen. The first
429
+ * message of a new chat loads history while the turn is still in flight and the
430
+ * user's message has not been persisted yet; replacing wholesale threw that
431
+ * message away, and the reply survived, so the symptom was a conversation with
432
+ * the answer but not the question.
433
+ *
434
+ * Only the same session, and only against losing messages: a longer or equal
435
+ * history still replaces, so an edit, a deletion made elsewhere and a compaction
436
+ * all land normally.
437
+ */
438
+ export function wouldShrinkHistory(params: {
439
+ currentSessionKey: string | null;
440
+ loadedSessionKey: string;
441
+ currentCount: number;
442
+ loadedCount: number;
443
+ }): boolean {
444
+ return (
445
+ params.currentSessionKey === params.loadedSessionKey &&
446
+ params.loadedCount < params.currentCount
447
+ );
448
+ }
449
+
450
+ /* ── the filter chain ────────────────────────────────────────────────────── */
451
+
452
+ /** A JSON blob a tool or an extension API returned, echoed as a message. */
453
+ function isToolResult(text: string): boolean {
454
+ const trimmed = text.trim();
455
+ const wrapped =
456
+ (trimmed.startsWith("[") && trimmed.endsWith("]")) ||
457
+ (trimmed.startsWith("{") && trimmed.endsWith("}"));
458
+ if (!wrapped) {
459
+ return false;
460
+ }
461
+ try {
462
+ const parsed = JSON.parse(trimmed);
463
+ return typeof parsed === "object" && parsed !== null;
464
+ } catch {
465
+ return false;
466
+ }
467
+ }
468
+
469
+ /** Raw skill-file content the model echoed instead of acting on. */
470
+ function isSkillContent(text: string): boolean {
471
+ const trimmed = text.trim();
472
+ return (
473
+ /^name:\s*\S+.*\bdescription:.*\btype:\s*skill\b/i.test(trimmed) ||
474
+ /^---\s*\n[\s\S]*?\btype:\s*skill\b[\s\S]*?\n---/m.test(trimmed)
475
+ );
476
+ }
477
+
478
+ /** Runtime diagnostics: token counters, a leaked api-key hint. */
479
+ function isAgentDiagnostics(text: string): boolean {
480
+ const trimmed = text.trim();
481
+ return /Tokens:\s*\d+.*Cache:.*Context:/s.test(trimmed) || /\bapi-key\s+sk-/.test(trimmed);
482
+ }
483
+
484
+ /** Tool execution output that leaked into history as its own message. */
485
+ function isToolOutput(text: string): boolean {
486
+ const trimmed = text.trim();
487
+ if (!trimmed) {
488
+ return false;
489
+ }
490
+ if (/^Tool\s+\S+\s+not found$/i.test(trimmed)) {
491
+ return true;
492
+ }
493
+ if (trimmed === "(no output)") {
494
+ return true;
495
+ }
496
+ if (/^total\s+\d+\s+[d-][rwx-]{9}/.test(trimmed)) {
497
+ return true;
498
+ }
499
+ if (/^[d-][rwx-]{9}[@+]?\s+\d+\s+\S+\s+\S+/.test(trimmed)) {
500
+ return true;
501
+ }
502
+ if (/(?:^|\n)[A-Z_]{2,}=\S/m.test(trimmed)) {
503
+ if (
504
+ /\b(?:PATH|PWD|HOME|USER|INIT_CWD|PNPM_|NODE_|npm_|CORTENA_|CORTENACORE_|CORTENABOT_)/m.test(
505
+ trimmed,
506
+ )
507
+ ) {
508
+ return true;
509
+ }
510
+ if ((trimmed.match(/(?:^|\n)[A-Z_]{3,}=\S/gm) ?? []).length >= 3) {
511
+ return true;
512
+ }
513
+ }
514
+ return false;
515
+ }
516
+
517
+ /**
518
+ * Should this message be hidden?
519
+ *
520
+ * The same chain cortenaweb's bubble applies, in the same order. It exists
521
+ * because tool plumbing leaks into a transcript in several recognisable shapes,
522
+ * and a chat that renders them reads as broken. Tool detail is not lost: it is
523
+ * in the tool strip and in the chain of thought.
524
+ *
525
+ * A reply that is only UI the agent drew has no text at all, so an A2UI block
526
+ * counts as content — without that, the surface the user asked for is filtered
527
+ * away and nothing appears.
528
+ */
529
+ export function isHiddenMessage(message: AgentChatMessage): boolean {
530
+ if (message.isError) {
531
+ return false;
532
+ }
533
+ if (message.isToolMessage) {
534
+ return true;
535
+ }
536
+ if (
537
+ message.role === "assistant" &&
538
+ !message.text.trim() &&
539
+ !message.thinkingText &&
540
+ !message.a2ui?.length
541
+ ) {
542
+ return true;
543
+ }
544
+ if (message.role === "user") {
545
+ return !message.text.trim() && !message.attachments?.length;
546
+ }
547
+ const text = message.text;
548
+ return isToolResult(text) || isSkillContent(text) || isAgentDiagnostics(text) || isToolOutput(text);
549
+ }