cortena-ui 1.6.0 → 1.7.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.
@@ -44,6 +44,7 @@ import type {
44
44
  AgentChatEvent,
45
45
  AgentChatHistoryResponse,
46
46
  AgentChatMessage,
47
+ AgentChatSendInput,
47
48
  AgentChatStep,
48
49
  AgentSession,
49
50
  AgentSessionsListResponse,
@@ -141,7 +142,18 @@ type Action =
141
142
  | { kind: "approval/denied"; id: string }
142
143
  | { kind: "approval/failed"; request: AgentApprovalRequest; message: string }
143
144
  | { kind: "session"; key: string | null }
144
- | { kind: "send"; message: AgentChatMessage | null; runId: string; sessionKey: string }
145
+ | {
146
+ kind: "send";
147
+ message: AgentChatMessage | null;
148
+ runId: string;
149
+ sessionKey: string;
150
+ /**
151
+ * A regenerate: no new bubble, but the user turn being asked again is
152
+ * moved onto this run by id, so the transcript still reads Q then A.
153
+ */
154
+ reanchor?: string;
155
+ }
156
+ | { kind: "message/remove"; id: string }
145
157
  | { kind: "send/failed"; message: string }
146
158
  | { kind: "abort" }
147
159
  | { kind: "history/loading" }
@@ -152,7 +164,13 @@ type Action =
152
164
  hasMore: boolean;
153
165
  records: number;
154
166
  }
155
- | { kind: "history/older"; messages: AgentChatMessage[]; hasMore: boolean; records: number }
167
+ | {
168
+ kind: "history/older";
169
+ sessionKey: string;
170
+ messages: AgentChatMessage[];
171
+ hasMore: boolean;
172
+ records: number;
173
+ }
156
174
  | { kind: "history/failed" }
157
175
  | { kind: "sessions/loading" }
158
176
  | { kind: "sessions/loaded"; sessions: AgentSession[] }
@@ -218,7 +236,13 @@ function reduce(state: AgentChatState, action: Action): AgentChatState {
218
236
  return {
219
237
  ...state,
220
238
  sessionKey: action.sessionKey,
221
- messages: action.message ? [...state.messages, action.message] : state.messages,
239
+ messages: action.message
240
+ ? [...state.messages, action.message]
241
+ : action.reanchor
242
+ ? state.messages.map((m) =>
243
+ m.id === action.reanchor ? { ...m, runId: action.runId } : m,
244
+ )
245
+ : state.messages,
222
246
  streaming: true,
223
247
  streamText: "",
224
248
  streamThinkingText: "",
@@ -237,6 +261,10 @@ function reduce(state: AgentChatState, action: Action): AgentChatState {
237
261
  approvals: [],
238
262
  error: null,
239
263
  };
264
+ case "message/remove":
265
+ // The bubble a regenerate replaces. Dropped before the new run starts so
266
+ // the answer takes its place rather than appending a second one.
267
+ return { ...state, messages: state.messages.filter((m) => m.id !== action.id) };
240
268
  case "send/failed":
241
269
  return {
242
270
  ...state,
@@ -273,6 +301,16 @@ function reduce(state: AgentChatState, action: Action): AgentChatState {
273
301
  case "history/loading":
274
302
  return { ...state, loadingHistory: true };
275
303
  case "history/loaded":
304
+ // A load for a session that is no longer the one on screen. Two loads
305
+ // race whenever a host switches sessions quickly — resume A, resume B,
306
+ // A's reply lands last — and applying it put A's transcript under B's
307
+ // key. The load that matters is the one for the CURRENT key; every other
308
+ // reply is stale and is dropped whole. `null` is included on purpose: a
309
+ // host that cleared the session while a load was in flight must not be
310
+ // put back into it by the reply.
311
+ if (action.sessionKey !== state.sessionKey) {
312
+ return { ...state, loadingHistory: false };
313
+ }
276
314
  if (
277
315
  wouldShrinkHistory({
278
316
  currentSessionKey: state.sessionKey,
@@ -303,6 +341,13 @@ function reduce(state: AgentChatState, action: Action): AgentChatState {
303
341
  streamA2ui: [],
304
342
  };
305
343
  case "history/older":
344
+ // The same rule as `history/loaded`: a page that was asked for under a
345
+ // key that is no longer the current one — the host switched sessions
346
+ // while it was in flight — must not be prepended to the transcript
347
+ // that is on screen now.
348
+ if (action.sessionKey !== state.sessionKey) {
349
+ return { ...state, loadingHistory: false };
350
+ }
306
351
  return {
307
352
  ...state,
308
353
  messages: [...action.messages, ...state.messages],
@@ -929,16 +974,68 @@ export interface UseAgentChatOptions {
929
974
  /** Skip reading and writing `sessionStorage`; for a transient mount. */
930
975
  ephemeral?: boolean;
931
976
  thinking?: "low" | "medium" | "high";
977
+ /**
978
+ * Mint the key for a brand-new session. Defaults to `mintSessionKey(client.agentId)`,
979
+ * which binds the session to this client's agent template. A host whose
980
+ * sessions are bound some other way — cortenaweb lets the user pick an agent
981
+ * per chat, and a chat with no pick is bound server-side to the default —
982
+ * supplies its own.
983
+ */
984
+ mintSessionKey?: () => string;
985
+ /**
986
+ * Read the stored session key back on mount. Default `true`. A host whose
987
+ * URL names the session — `/chat/<key>` — is the source of truth itself and
988
+ * passes `false`: the key is still WRITTEN, so the rule "one window, one
989
+ * session" holds, but nothing is restored under the URL's feet and no
990
+ * history is fetched for a session the page is about to leave.
991
+ */
992
+ restoreSession?: boolean;
993
+ /**
994
+ * Fetch `sessions.list` on mount and after each send. Default `true`. A host
995
+ * with its own session list — cortenaweb's sidebar — passes `false` and the
996
+ * hook makes no session calls at all; `sessions` stays empty.
997
+ */
998
+ listSessions?: boolean;
999
+ }
1000
+
1001
+ /** What `send` accepts beside the text and the attachments. */
1002
+ export interface AgentChatSendOptions {
1003
+ /** The model for this run; the composer's selector. */
1004
+ model?: AgentChatSendInput["model"];
1005
+ /** Overrides the hook-level `thinking` for this run. */
1006
+ thinking?: AgentChatSendInput["thinking"];
932
1007
  }
933
1008
 
934
1009
  export interface UseAgentChatResult extends AgentChatState {
935
- send: (text: string, attachments?: unknown[]) => void;
1010
+ send: (text: string, attachments?: unknown[], options?: AgentChatSendOptions) => void;
936
1011
  sendAction: (action: NonNullable<Parameters<AgentChatClient["send"]>[0]["a2uiAction"]>) => void;
1012
+ /** A click inside a rendered MCP App, as the next run's input. No user bubble. */
1013
+ sendMcpAppAction: (action: NonNullable<AgentChatSendInput["mcpAppAction"]>) => void;
937
1014
  abort: () => void;
938
1015
  resolveApproval: (id: string, decision: AgentApprovalDecision) => void;
939
1016
  newSession: () => void;
940
1017
  resumeSession: (key: string) => void;
941
- loadOlder: () => void;
1018
+ /**
1019
+ * Leave the session without starting another. The transcript empties, the
1020
+ * stored key is removed and the next `send` mints a fresh key — which is
1021
+ * what a host's "New chat" wants when the URL should not change until the
1022
+ * first message goes out. `newSession` mints immediately; this does not.
1023
+ */
1024
+ clearSession: () => void;
1025
+ /**
1026
+ * Ask again for the message this one answered: the bubble is dropped and the
1027
+ * user turn before it is sent again, so the new answer takes its place. Also
1028
+ * the Retry on a failed turn. A no-op while a run is live or when nothing
1029
+ * precedes the message.
1030
+ */
1031
+ regenerate: (messageId: string) => void;
1032
+ /**
1033
+ * Ask for the next page of older history. `true` when a load was started;
1034
+ * `false` when there is nothing to do — no session, a load already in
1035
+ * flight, or no more history — so a caller that saves scroll position
1036
+ * before a prepend knows whether one is coming.
1037
+ */
1038
+ loadOlder: () => boolean;
942
1039
  refreshSessions: () => void;
943
1040
  }
944
1041
 
@@ -947,6 +1044,9 @@ export function useAgentChat({
947
1044
  storageScope,
948
1045
  ephemeral,
949
1046
  thinking,
1047
+ mintSessionKey: mintKey,
1048
+ restoreSession = true,
1049
+ listSessions = true,
950
1050
  }: UseAgentChatOptions): UseAgentChatResult {
951
1051
  const [state, dispatch] = React.useReducer(reduce, EMPTY);
952
1052
  const stateRef = React.useRef(state);
@@ -973,6 +1073,9 @@ export function useAgentChat({
973
1073
  }, [client]);
974
1074
 
975
1075
  const refreshSessions = React.useCallback(() => {
1076
+ if (!listSessions) {
1077
+ return;
1078
+ }
976
1079
  dispatch({ kind: "sessions/loading" });
977
1080
  void client
978
1081
  .request<AgentSessionsListResponse>("sessions.list", { includeDerivedTitles: true })
@@ -988,7 +1091,7 @@ export function useAgentChat({
988
1091
  dispatch({ kind: "sessions/loaded", sessions: [] });
989
1092
  dispatch({ kind: "error", message: errorText(err) });
990
1093
  });
991
- }, [client]);
1094
+ }, [client, listSessions]);
992
1095
 
993
1096
  const loadHistory = React.useCallback(
994
1097
  (sessionKey: string) => {
@@ -1018,7 +1121,7 @@ export function useAgentChat({
1018
1121
  return;
1019
1122
  }
1020
1123
  restored.current = true;
1021
- const stored = ephemeral ? null : readStoredSessionKey(storageScope);
1124
+ const stored = ephemeral || !restoreSession ? null : readStoredSessionKey(storageScope);
1022
1125
  const key = stored && isSessionOfAgent(stored, client.agentId) ? stored : null;
1023
1126
  if (key) {
1024
1127
  dispatch({ kind: "session", key });
@@ -1032,7 +1135,7 @@ export function useAgentChat({
1032
1135
  loadHistory(key);
1033
1136
  }
1034
1137
  refreshSessions();
1035
- }, [client, ephemeral, loadHistory, refreshSessions, storageScope]);
1138
+ }, [client, ephemeral, loadHistory, refreshSessions, restoreSession, storageScope]);
1036
1139
 
1037
1140
  /**
1038
1141
  * Keep the per-window store in step with the strip, on a debounce.
@@ -1086,7 +1189,12 @@ export function useAgentChat({
1086
1189
  );
1087
1190
 
1088
1191
  const run = React.useCallback(
1089
- (text: string, extra: Partial<Parameters<AgentChatClient["send"]>[0]> = {}) => {
1192
+ (
1193
+ text: string,
1194
+ extra: Partial<Parameters<AgentChatClient["send"]>[0]> = {},
1195
+ /** The user message this run asks again, instead of a new bubble. */
1196
+ reanchor?: string,
1197
+ ) => {
1090
1198
  const current = stateRef.current;
1091
1199
  // One run at a time. A second `send` on a streaming session opens a
1092
1200
  // second stream into one store: two sets of deltas overwrite each
@@ -1097,7 +1205,8 @@ export function useAgentChat({
1097
1205
  if (current.streaming) {
1098
1206
  return;
1099
1207
  }
1100
- const sessionKey = current.sessionKey ?? mintSessionKey(client.agentId);
1208
+ const sessionKey =
1209
+ current.sessionKey ?? (mintKey ? mintKey() : mintSessionKey(client.agentId));
1101
1210
  if (!current.sessionKey && !ephemeral) {
1102
1211
  writeStoredSessionKey(storageScope, sessionKey);
1103
1212
  }
@@ -1106,10 +1215,19 @@ export function useAgentChat({
1106
1215
  // for it would put `{"name":"createTask"}` in the transcript as though
1107
1216
  // the user had said it.
1108
1217
  const message: AgentChatMessage | null =
1109
- text || (!extra.a2uiAction && !extra.mcpAppAction)
1110
- ? { id: runId, role: "user", text, timestamp: Date.now(), runId }
1218
+ !reanchor && (text || (!extra.a2uiAction && !extra.mcpAppAction))
1219
+ ? {
1220
+ id: runId,
1221
+ role: "user",
1222
+ text,
1223
+ timestamp: Date.now(),
1224
+ runId,
1225
+ // On the bubble from the start, so the host's strip draws what
1226
+ // was attached before the transcript is persisted.
1227
+ ...(extra.attachments?.length ? { attachmentItems: extra.attachments } : {}),
1228
+ }
1111
1229
  : null;
1112
- dispatch({ kind: "send", message, runId, sessionKey });
1230
+ dispatch({ kind: "send", message, runId, sessionKey, ...(reanchor ? { reanchor } : {}) });
1113
1231
  void client
1114
1232
  .send({
1115
1233
  sessionKey,
@@ -1121,12 +1239,16 @@ export function useAgentChat({
1121
1239
  .then(() => refreshSessions())
1122
1240
  .catch((err: unknown) => dispatch({ kind: "send/failed", message: errorText(err) }));
1123
1241
  },
1124
- [client, ephemeral, refreshSessions, storageScope, thinking],
1242
+ [client, ephemeral, mintKey, refreshSessions, storageScope, thinking],
1125
1243
  );
1126
1244
 
1127
1245
  const send = React.useCallback(
1128
- (text: string, attachments?: unknown[]) => {
1129
- run(text, attachments?.length ? { attachments } : {});
1246
+ (text: string, attachments?: unknown[], options?: AgentChatSendOptions) => {
1247
+ run(text, {
1248
+ ...(attachments?.length ? { attachments } : {}),
1249
+ ...(options?.model ? { model: options.model } : {}),
1250
+ ...(options?.thinking ? { thinking: options.thinking } : {}),
1251
+ });
1130
1252
  },
1131
1253
  [run],
1132
1254
  );
@@ -1138,6 +1260,43 @@ export function useAgentChat({
1138
1260
  [run],
1139
1261
  );
1140
1262
 
1263
+ const sendMcpAppAction = React.useCallback(
1264
+ (mcpAppAction: NonNullable<AgentChatSendInput["mcpAppAction"]>) => {
1265
+ run("", { mcpAppAction });
1266
+ },
1267
+ [run],
1268
+ );
1269
+
1270
+ const regenerate = React.useCallback(
1271
+ (messageId: string) => {
1272
+ const { messages, streaming } = stateRef.current;
1273
+ if (streaming) {
1274
+ return;
1275
+ }
1276
+ const index = messages.findIndex((m) => m.id === messageId);
1277
+ if (index < 1) {
1278
+ return;
1279
+ }
1280
+ let question: AgentChatMessage | null = null;
1281
+ for (let i = index - 1; i >= 0; i -= 1) {
1282
+ const candidate = messages[i];
1283
+ if (candidate?.role === "user" && !candidate.isToolMessage) {
1284
+ question = candidate;
1285
+ break;
1286
+ }
1287
+ }
1288
+ if (question === null) {
1289
+ return;
1290
+ }
1291
+ dispatch({ kind: "message/remove", id: messageId });
1292
+ // The question is already in the transcript. It is re-sent, not
1293
+ // re-drawn: the existing bubble is moved onto the new run so the
1294
+ // transcript reads Q then the new A, not Q, Q, A.
1295
+ run(question.text, {}, question.id);
1296
+ },
1297
+ [run],
1298
+ );
1299
+
1141
1300
  const abort = React.useCallback(() => {
1142
1301
  const { runId, sessionKey } = stateRef.current;
1143
1302
  dispatch({ kind: "abort" });
@@ -1180,8 +1339,15 @@ export function useAgentChat({
1180
1339
  );
1181
1340
 
1182
1341
  const newSession = React.useCallback(() => {
1183
- setSession(mintSessionKey(client.agentId));
1184
- }, [client, setSession]);
1342
+ setSession(mintKey ? mintKey() : mintSessionKey(client.agentId));
1343
+ }, [client, mintKey, setSession]);
1344
+
1345
+ const clearSession = React.useCallback(() => {
1346
+ dispatch({ kind: "session", key: null });
1347
+ if (!ephemeral) {
1348
+ writeStoredSessionKey(storageScope, null);
1349
+ }
1350
+ }, [ephemeral, storageScope]);
1185
1351
 
1186
1352
  const resumeSession = React.useCallback(
1187
1353
  (key: string) => {
@@ -1191,15 +1357,16 @@ export function useAgentChat({
1191
1357
  [loadHistory, setSession],
1192
1358
  );
1193
1359
 
1194
- const loadOlder = React.useCallback(() => {
1360
+ const loadOlder = React.useCallback((): boolean => {
1195
1361
  const current = stateRef.current;
1196
1362
  if (!current.sessionKey || current.loadingHistory || !current.hasMoreHistory) {
1197
- return;
1363
+ return false;
1198
1364
  }
1365
+ const sessionKey = current.sessionKey;
1199
1366
  dispatch({ kind: "history/loading" });
1200
1367
  void client
1201
1368
  .request<AgentChatHistoryResponse>("chat.history", {
1202
- sessionKey: current.sessionKey,
1369
+ sessionKey,
1203
1370
  limit: 100,
1204
1371
  // Records, not bubbles. See `historyCount`.
1205
1372
  offset: current.historyCount,
@@ -1208,22 +1375,27 @@ export function useAgentChat({
1208
1375
  const records = response?.messages ?? [];
1209
1376
  dispatch({
1210
1377
  kind: "history/older",
1378
+ sessionKey,
1211
1379
  messages: parseHistoryMessages(records),
1212
1380
  hasMore: response?.hasMore ?? false,
1213
1381
  records: records.length,
1214
1382
  });
1215
1383
  })
1216
1384
  .catch(() => dispatch({ kind: "history/failed" }));
1385
+ return true;
1217
1386
  }, [client]);
1218
1387
 
1219
1388
  return {
1220
1389
  ...state,
1221
1390
  send,
1222
1391
  sendAction,
1392
+ sendMcpAppAction,
1223
1393
  abort,
1224
1394
  resolveApproval,
1225
1395
  newSession,
1226
1396
  resumeSession,
1397
+ clearSession,
1398
+ regenerate,
1227
1399
  loadOlder,
1228
1400
  refreshSessions,
1229
1401
  };
@@ -57,7 +57,18 @@ export interface AgentChatMessage {
57
57
  role: "user" | "assistant";
58
58
  text: string;
59
59
  timestamp: number;
60
+ /** Attachment names or references, as a transcript record carries them. */
60
61
  attachments?: string[];
62
+ /**
63
+ * What the user attached to this turn, as the host's composer shaped it.
64
+ * `unknown[]` because the shape is the host's — cortenaweb sends
65
+ * `{ fileName, mimeType, content }` objects — and this package only asks
66
+ * whether there are any. Set on the user message `send` adds, so the bubble
67
+ * can draw the strip before the transcript is persisted. Separate from
68
+ * `attachments` so that field keeps its `string[]` shape for every consumer
69
+ * already reading it.
70
+ */
71
+ attachmentItems?: unknown[];
61
72
  /** The chain of thought behind this turn, collapsed by default. */
62
73
  thinkingText?: string;
63
74
  isError?: boolean;
@@ -40,7 +40,7 @@ import { Maximize2, Minimize2, X } from "lucide-react";
40
40
  import * as React from "react";
41
41
  import { BrandMark } from "@/components/app-shell";
42
42
  import { Button } from "@/components/button";
43
- import { AgentChat, type AgentChatProps } from "@/components/agent-chat";
43
+ import { AgentChat, type AgentChatOwnedProps } from "@/components/agent-chat";
44
44
  import { viewStorageKey, windowSessionStorage } from "@/agent-chat/session";
45
45
  import type { AgentChatClient } from "@/agent-chat/types";
46
46
  import { cn } from "@/lib/cn";
@@ -56,8 +56,8 @@ export interface AgentChatPopupExtension {
56
56
 
57
57
  export interface AgentChatPopupProps
58
58
  extends Omit<
59
- AgentChatProps,
60
- "agentName" | "storageScope" | "className" | "headerSlot" | "client"
59
+ AgentChatOwnedProps,
60
+ "agentName" | "storageScope" | "className" | "headerSlot" | "client" | "chat"
61
61
  > {
62
62
  extension: AgentChatPopupExtension;
63
63
  /** The transport. Its `agentId` is the template slug the sessions belong to. */