@cosmoops/connect-sdk 0.28.0 → 0.30.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,123 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.30.0
7
+
8
+ `<ConnectAskAi>` forgot everything on reload. Connect's own panel has had
9
+ History and a New-conversation button since it shipped; this is the same two
10
+ controls, backed by the same storage, for the panel a partner mounts.
11
+
12
+ The reason it took a server change rather than a component one: the
13
+ application chat lane deliberately stored nothing, because a conversation
14
+ belongs to a *person* and an API key names an application. That is still true
15
+ of a server-to-server call — it persists nothing, exactly as before. What
16
+ changed is that the browser path was already sending a signed-in user's
17
+ session token, so there has been a real owner to file a transcript under for
18
+ some time, and nothing was doing it.
19
+
20
+ ### Added
21
+
22
+ - **History and New in `<ConnectAskAi>`.** Two buttons in the panel header. New
23
+ clears the transcript and starts a fresh conversation without deleting the
24
+ old one; History lists what this user has asked *in your application*, and
25
+ pressing a row reopens the transcript and keeps writing into it.
26
+
27
+ Nothing to wire up: if your API key carries `ai:chat` it already works. There
28
+ is no prop to turn it on, and none to turn it off — a chat panel that cannot
29
+ show you what you asked yesterday is the odd one out, not the default.
30
+
31
+ - `connect.ai.listConversations({ asUser, limit?, cursor? })`,
32
+ `connect.ai.getConversation({ conversationId, asUser })` and
33
+ `connect.ai.deleteConversation({ conversationId, asUser })`, plus the types
34
+ they answer with: `AiConversation`, `AiConversationList`,
35
+ `AiConversationTurn` and `AiConversationDetail`.
36
+
37
+ `asUser` is **required** on all three, unlike on `ai.chat` where it is
38
+ optional. There is no application-wide view of these rows to fall back to:
39
+ they are scoped to a person, and a version of this call that read every
40
+ user's transcripts is not a smaller feature, it is a different and worse one.
41
+
42
+ `turns[].parts` comes back as the AI SDK's own `UIMessage` parts, in the
43
+ order the model produced them — prose, reasoning and tool calls interleaved.
44
+ Hand them to your chat UI as they are. Flattening a turn to text loses the
45
+ model's own argument and makes the conversation unresumable, which is why
46
+ these are typed `unknown[]` rather than restated in a schema that would start
47
+ rejecting transcripts the day the AI SDK adds a part kind.
48
+
49
+ - `connect.ai.chat({ conversationId })`, optional. Generate one id per
50
+ conversation client-side and send it with every turn of it; Connect upserts
51
+ against it. **No id, nothing stored** — which is how a caller opts out of
52
+ history entirely, and it stays the behaviour for any call made without
53
+ `asUser`.
54
+
55
+ - `createConnectHandler` answers three more routes —
56
+ `GET /ai/conversations`, `GET /ai/conversation?id=`,
57
+ `DELETE /ai/conversation?id=` — and relays `conversationId` on
58
+ `POST /ai/chat`. The owner comes from the caller's own session on all four;
59
+ there is no parameter that could name somebody else.
60
+
61
+ ### Changed
62
+
63
+ - `mutateResource(path, body?, method?)` takes an optional third argument,
64
+ `"POST"` (the default, and what every existing call gets) or `"DELETE"`.
65
+ Existing calls are unaffected.
66
+
67
+ ### Notes
68
+
69
+ - **Scoped to your application, not just to the workspace.** A workspace
70
+ subscribing to two products on Connect keeps two separate histories, and
71
+ neither can read the other's — nor the one that user keeps in the Connect
72
+ portal itself. An id from the wrong one answers `404`, not somebody else's
73
+ transcript.
74
+
75
+ - **A transcript is not an audit trail.** It records what was asked and
76
+ answered, and a user can delete their own. If you need a durable record of
77
+ something the assistant *did*, that is your own write and your own log.
78
+
79
+ ## 0.29.0
80
+
81
+ Voice was readable and writing was not. `calls.list` has always let a partner
82
+ read back what a customer said out loud; the WhatsApp threads a workspace's
83
+ agents hold had no partner surface at all, so a CRM could show every call on a
84
+ contact and nothing they had typed.
85
+
86
+ ### Added
87
+
88
+ - `connect.conversations.list({ workspaceId, contactAddress?, status? })` and
89
+ `connect.conversations.get(id, { workspaceId })`, plus the four types they
90
+ answer with: `ConnectConversation`, `ConnectConversationMessage`,
91
+ `ConversationsPage` and `ConversationDetail`.
92
+
93
+ **These are the *workspace's* threads, not your application's**, which is the
94
+ difference from `calls` and it decides how they are gated. A call arrived on
95
+ a channel you hold, so `calls.list` is scoped to your application by the key
96
+ alone. A conversation lives in the workspace's own subtree and belongs to it
97
+ the way its contact book does — so reading one needs the `conversations:read`
98
+ scope **and** a live subscription in that workspace, and, when the call comes
99
+ through the browser bridge, the signed-in member's own `agent:read`.
100
+
101
+ Three things are worth reading before you render one:
102
+
103
+ `contactAddress` is the filter to reach for beside your own customer record.
104
+ You hold a phone number, and you cannot derive a conversation id from one —
105
+ the id is built from a hashed key, deliberately, so an id you could guess is
106
+ an id you could probe. Send the number however your records spell it.
107
+
108
+ `canReplyFreely` is computed against Connect's clock, and re-deriving it from
109
+ `lastInboundAt` against yours is the mistake to avoid: WhatsApp allows a
110
+ free-form reply only inside 24 hours of the customer's last message, and a
111
+ skew between your server and Connect's shows up as a thread your UI calls
112
+ answerable when it is not.
113
+
114
+ Messages that sent nothing are included and carry `failure`. Render them — a
115
+ transcript that silently drops them reads as a customer being ignored, which
116
+ is the opposite of what happened. `refused` is not a failure at all: it is
117
+ the agent deciding not to answer, which is what it is for.
118
+
119
+ Read-only, and deliberately. Sending on a thread would mean saying whether it
120
+ is a free-form reply or an approved template, and what that means for the
121
+ agent still answering it.
122
+
6
123
  ## 0.28.0
7
124
 
8
125
  Connect's webhook bus carried four events, all of them about billing and
@@ -0,0 +1,106 @@
1
+ "use client";
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.AskAiHistory = AskAiHistory;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const react_2 = require("@heroui/react");
8
+ const lucide_react_1 = require("lucide-react");
9
+ const provider_js_1 = require("../provider.js");
10
+ /** Bridge paths, spelled once - the list is read and invalidated from three places. */
11
+ const LIST_PATH = "/ai/conversations";
12
+ const detailPath = (id) => `/ai/conversation?id=${encodeURIComponent(id)}`;
13
+ /**
14
+ * Past Ask AI conversations, for reopening or deleting.
15
+ *
16
+ * ## It fetches only while it is open
17
+ *
18
+ * `<ConnectAskAi>` is mounted for the whole application shell, so an
19
+ * unconditional read here would put a request on every page load to populate a
20
+ * list most people never open. It reads on open, and again whenever the panel
21
+ * has written a new conversation since - which is what `refreshKey` is for.
22
+ *
23
+ * ## Deletion confirms inline, not in a dialog
24
+ *
25
+ * Destructive actions confirm; this one does it at the weight it deserves.
26
+ * Losing a transcript is recoverable by asking again, not a cancelled
27
+ * subscription, and a modal over a 400px panel would be heavier than the thing
28
+ * it guards. The row swaps to "Delete? / Cancel" and stays there until
29
+ * answered.
30
+ */
31
+ function AskAiHistory({ refreshKey, activeConversationId, onOpenConversation, }) {
32
+ const { fetchResource, mutateResource, invalidateResource } = (0, provider_js_1.useConnect)();
33
+ const [conversations, setConversations] = (0, react_1.useState)(null);
34
+ const [error, setError] = (0, react_1.useState)(null);
35
+ const [confirmingId, setConfirmingId] = (0, react_1.useState)(null);
36
+ const [busyId, setBusyId] = (0, react_1.useState)(null);
37
+ (0, react_1.useEffect)(() => {
38
+ let active = true;
39
+ // The cached entry is dropped first. This component mounts only when the
40
+ // user asks for the list, and the provider's cache would otherwise hand
41
+ // them the copy taken before the last three questions they asked.
42
+ invalidateResource(LIST_PATH);
43
+ fetchResource(LIST_PATH)
44
+ .then((page) => {
45
+ if (!active)
46
+ return;
47
+ setConversations(page.conversations);
48
+ setError(null);
49
+ })
50
+ .catch((cause) => {
51
+ if (!active)
52
+ return;
53
+ // An empty list rather than a permanent skeleton: the error is shown
54
+ // beside it, and a panel stuck loading says nothing at all.
55
+ setConversations([]);
56
+ setError(cause instanceof Error ? cause.message : "Could not load your conversations");
57
+ });
58
+ return () => {
59
+ active = false;
60
+ };
61
+ }, [fetchResource, invalidateResource, refreshKey]);
62
+ const open = async (id) => {
63
+ setBusyId(id);
64
+ try {
65
+ const detail = await fetchResource(detailPath(id));
66
+ onOpenConversation(id, detail);
67
+ }
68
+ catch (cause) {
69
+ setError(cause instanceof Error ? cause.message : "Could not open that conversation");
70
+ }
71
+ finally {
72
+ setBusyId(null);
73
+ }
74
+ };
75
+ const remove = async (id) => {
76
+ setBusyId(id);
77
+ // Removed from the list before the response lands. The confirmation was the
78
+ // decision point, and a row that lingers while the request flies reads as a
79
+ // press that did not take - so the list is patched and put back on failure.
80
+ const previous = conversations;
81
+ setConversations((current) => current?.filter((row) => row.id !== id) ?? null);
82
+ setConfirmingId(null);
83
+ try {
84
+ await mutateResource(detailPath(id), undefined, "DELETE");
85
+ invalidateResource(LIST_PATH);
86
+ }
87
+ catch (cause) {
88
+ setConversations(previous);
89
+ setError(cause instanceof Error ? cause.message : "Could not delete that conversation");
90
+ }
91
+ finally {
92
+ setBusyId(null);
93
+ }
94
+ };
95
+ if (conversations === null) {
96
+ return ((0, jsx_runtime_1.jsx)("div", { className: "space-y-2 p-4", "aria-busy": "true", children: [0, 1, 2].map((row) => ((0, jsx_runtime_1.jsx)("div", { className: "h-9 animate-pulse rounded-md bg-surface-secondary" }, row))) }));
97
+ }
98
+ if (conversations.length === 0) {
99
+ return ((0, jsx_runtime_1.jsx)("div", { className: "p-4", children: error ? ((0, jsx_runtime_1.jsx)("p", { className: "text-xs text-danger", children: error })) : ((0, jsx_runtime_1.jsx)("p", { className: "text-center text-xs text-muted", children: "Nothing here yet. Conversations you have are saved automatically." })) }));
100
+ }
101
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "p-2", children: [error && (0, jsx_runtime_1.jsx)("p", { className: "px-2 pb-2 text-xs text-danger", children: error }), (0, jsx_runtime_1.jsx)("ul", { className: "space-y-0.5", children: conversations.map((conversation) => {
102
+ const isConfirming = confirmingId === conversation.id;
103
+ const isActive = conversation.id === activeConversationId;
104
+ return ((0, jsx_runtime_1.jsx)("li", { children: isConfirming ? ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 rounded-md border border-border px-2 py-1.5", children: [(0, jsx_runtime_1.jsx)("span", { className: "flex-1 truncate text-xs text-muted", children: "Delete this conversation?" }), (0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "danger", className: "h-7 px-2 text-xs", isDisabled: busyId === conversation.id, onPress: () => void remove(conversation.id), children: "Delete" }), (0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "tertiary", className: "h-7 px-2 text-xs", onPress: () => setConfirmingId(null), children: "Cancel" })] })) : ((0, jsx_runtime_1.jsxs)("div", { className: `group flex items-center gap-1 rounded-md ${isActive ? "bg-surface-secondary" : ""}`, children: [(0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "ghost", className: "h-auto min-w-0 flex-1 justify-start gap-2 px-2 py-1.5 text-start", isDisabled: busyId === conversation.id, onPress: () => void open(conversation.id), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.MessageSquare, { className: "size-3.5 shrink-0 text-muted" }), (0, jsx_runtime_1.jsx)("span", { className: "min-w-0 flex-1 truncate text-xs font-normal", children: conversation.title })] }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "ghost", className: "shrink-0", "aria-label": `Delete "${conversation.title}"`, onPress: () => setConfirmingId(conversation.id), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { className: "size-3.5 text-muted" }) })] })) }, conversation.id));
105
+ }) })] }));
106
+ }
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.lastTextBlockIndex = lastTextBlockIndex;
4
+ exports.appendText = appendText;
5
+ exports.turnsFromStored = turnsFromStored;
6
+ /**
7
+ * Index of the final text block, or `-1`. A reverse loop rather than
8
+ * `findLastIndex`, which is ES2023 - this package targets ES2022 so its
9
+ * published output stays usable on the runtimes its `engines` field claims.
10
+ */
11
+ function lastTextBlockIndex(blocks) {
12
+ for (let index = blocks.length - 1; index >= 0; index--) {
13
+ if (blocks[index]?.kind === "text")
14
+ return index;
15
+ }
16
+ return -1;
17
+ }
18
+ /** Appends a text delta to the open text block, or opens one after a widget. */
19
+ function appendText(blocks, text) {
20
+ const last = blocks.at(-1);
21
+ if (last?.kind === "text") {
22
+ return [...blocks.slice(0, -1), { kind: "text", text: last.text + text }];
23
+ }
24
+ return [...blocks, { kind: "text", text }];
25
+ }
26
+ /**
27
+ * Rebuilds the panel's turns from a stored transcript.
28
+ *
29
+ * The inverse of what the stream reader does live, and it has to agree with it
30
+ * or a reopened conversation renders differently from the one the user just
31
+ * had. Two shapes, one source: the stream delivers `text-delta`,
32
+ * `reasoning-delta` and `tool-input-available` frames, and storage keeps the
33
+ * settled `UIMessage` parts those frames built - `{ type: "text" }`,
34
+ * `{ type: "reasoning" }`, and `{ type: "tool-<name>" }` carrying the input the
35
+ * widget was drawn from.
36
+ *
37
+ * Three rules, each of which is a thing that would otherwise render wrong:
38
+ *
39
+ * - **A tool part with no input is dropped, not restored.** `input: null` means
40
+ * "still arriving" to the renderer, which draws a skeleton - correct live,
41
+ * and a shimmer that never resolves on a transcript that finished days ago.
42
+ * - **Reasoning is hoisted per turn**, not left in place. It is rendered above
43
+ * the answer in a disclosure, which is where it sat while streaming.
44
+ * - **A part this build cannot place is skipped rather than guessed at.**
45
+ * `parts` is the AI SDK's own shape and gains kinds over time; an unknown one
46
+ * costs a widget, and inventing a rendering for it costs the whole turn.
47
+ */
48
+ function turnsFromStored(turns) {
49
+ return turns.map((turn) => {
50
+ let blocks = [];
51
+ let reasoning = "";
52
+ for (const raw of turn.parts) {
53
+ const part = raw;
54
+ if (typeof part?.type !== "string")
55
+ continue;
56
+ if (part.type === "text") {
57
+ if (typeof part.text === "string" && part.text.length > 0) {
58
+ blocks = appendText(blocks, part.text);
59
+ }
60
+ continue;
61
+ }
62
+ if (part.type === "reasoning") {
63
+ if (typeof part.text === "string")
64
+ reasoning += part.text;
65
+ continue;
66
+ }
67
+ if (part.type.startsWith("tool-")) {
68
+ // See the note above: an absent input is a skeleton forever.
69
+ if (part.input === undefined || part.input === null)
70
+ continue;
71
+ blocks = [
72
+ ...blocks,
73
+ {
74
+ kind: "tool",
75
+ // The stored call id when there is one. Falling back to the block's
76
+ // position keeps React keys unique within the turn, which is all
77
+ // this id is used for once the turn is settled.
78
+ id: typeof part.toolCallId === "string" ? part.toolCallId : `${turn.id}-${blocks.length}`,
79
+ name: part.type.slice("tool-".length),
80
+ input: part.input,
81
+ },
82
+ ];
83
+ }
84
+ }
85
+ return { id: turn.id, role: turn.role, blocks, reasoning };
86
+ });
87
+ }
@@ -11,6 +11,8 @@ const provider_js_1 = require("../provider.js");
11
11
  const use_active_path_js_1 = require("../use-active-path.js");
12
12
  const ai_screens_js_1 = require("./ai-screens.js");
13
13
  const ai_widgets_js_1 = require("./ai-widgets.js");
14
+ const ask_ai_history_js_1 = require("./ask-ai-history.js");
15
+ const ask_ai_turns_js_1 = require("./ask-ai-turns.js");
14
16
  /**
15
17
  * The panel's open width. The *inner* pane always carries it, so the content
16
18
  * keeps its layout while the outer shell's width animates to zero around it.
@@ -170,26 +172,6 @@ async function readStream(response, onChunk) {
170
172
  }
171
173
  }
172
174
  }
173
- /**
174
- * Index of the final text block, or `-1`. A reverse loop rather than
175
- * `findLastIndex`, which is ES2023 - this package targets ES2022 so its
176
- * published output stays usable on the runtimes its `engines` field claims.
177
- */
178
- function lastTextBlockIndex(blocks) {
179
- for (let index = blocks.length - 1; index >= 0; index--) {
180
- if (blocks[index]?.kind === "text")
181
- return index;
182
- }
183
- return -1;
184
- }
185
- /** Appends a text delta to the open text block, or opens one after a widget. */
186
- function appendText(blocks, text) {
187
- const last = blocks.at(-1);
188
- if (last?.kind === "text") {
189
- return [...blocks.slice(0, -1), { kind: "text", text: last.text + text }];
190
- }
191
- return [...blocks, { kind: "text", text }];
192
- }
193
175
  /**
194
176
  * Ask AI - a chat panel grounded in the signed-in workspace's Context Index,
195
177
  * streamed through your own bridge.
@@ -238,6 +220,31 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
238
220
  const [input, setInput] = (0, react_1.useState)("");
239
221
  const [isBusy, setIsBusy] = (0, react_1.useState)(false);
240
222
  const [error, setError] = (0, react_1.useState)(null);
223
+ const [showHistory, setShowHistory] = (0, react_1.useState)(false);
224
+ /**
225
+ * The conversation being written to.
226
+ *
227
+ * Generated here, before the first message, because the server cannot hand
228
+ * one back: it is streaming a response, and there is no point in that stream
229
+ * at which a new id could reach the browser ahead of the first token. So the
230
+ * client picks it and Connect upserts - safe because ownership is checked
231
+ * rather than assumed, and an id naming somebody else's conversation is
232
+ * refused.
233
+ *
234
+ * Lazily initialised rather than computed in a `useMemo`: this has to survive
235
+ * re-renders and change only when the user starts a new conversation, which
236
+ * is precisely what `useMemo` does not promise.
237
+ */
238
+ const [conversationId, setConversationId] = (0, react_1.useState)(() => newConversationId());
239
+ /**
240
+ * Bumped whenever a turn has been written, so the history list re-reads the
241
+ * next time it is opened.
242
+ *
243
+ * A counter rather than a refetch call, because the list is unmounted while
244
+ * closed - there is nothing to tell. It is *why* the list re-reads, not the
245
+ * list itself.
246
+ */
247
+ const [writeCount, setWriteCount] = (0, react_1.useState)(0);
241
248
  const abortRef = (0, react_1.useRef)(null);
242
249
  const bottomRef = (0, react_1.useRef)(null);
243
250
  // Ids only have to be unique within one mounted conversation, so a counter is
@@ -312,6 +319,11 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
312
319
  // decide what anyone may do. See `ConnectScreen`.
313
320
  ...(resolvedPath ? { screenContext: { pathname: resolvedPath } } : {}),
314
321
  ...(screens.length > 0 ? { screens } : {}),
322
+ // What makes the conversation resumable. Connect stores the turn
323
+ // against it and `<AskAiHistory>` reads it back; a turn sent
324
+ // without one is answered exactly the same way and remembered by
325
+ // nobody.
326
+ conversationId,
315
327
  }, { signal: controller.signal });
316
328
  // The assistant's turn is appended only once the stream is live, so a
317
329
  // failed request leaves an error under the question rather than an
@@ -319,7 +331,7 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
319
331
  setTurns((current) => [...current, { id: answerId, role: "assistant", blocks: [], reasoning: "" }]);
320
332
  await readStream(response, (chunk) => {
321
333
  if (chunk.text) {
322
- patchAnswer((turn) => ({ ...turn, blocks: appendText(turn.blocks, chunk.text) }));
334
+ patchAnswer((turn) => ({ ...turn, blocks: (0, ask_ai_turns_js_1.appendText)(turn.blocks, chunk.text) }));
323
335
  }
324
336
  if (chunk.reasoning) {
325
337
  patchAnswer((turn) => ({ ...turn, reasoning: turn.reasoning + chunk.reasoning }));
@@ -348,13 +360,46 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
348
360
  if (abortRef.current === controller)
349
361
  abortRef.current = null;
350
362
  setIsBusy(false);
363
+ // Even on an aborted or failed turn: the user's own question has
364
+ // already been persisted by the time anything can go wrong downstream
365
+ // of it, so the list is stale either way.
366
+ setWriteCount((count) => count + 1);
351
367
  }
352
- }, [streamResource, turns]);
368
+ }, [streamResource, turns, conversationId]);
369
+ /**
370
+ * Clears the transcript and starts writing into a fresh conversation.
371
+ *
372
+ * The old one is not deleted - it is in History, which is the whole point.
373
+ */
374
+ const startNewConversation = (0, react_1.useCallback)(() => {
375
+ abortRef.current?.abort();
376
+ abortRef.current = null;
377
+ setIsBusy(false);
378
+ setTurns([]);
379
+ setError(null);
380
+ setShowHistory(false);
381
+ setConversationId(newConversationId());
382
+ }, []);
383
+ /**
384
+ * Replaces the transcript with a stored one, and continues writing into it.
385
+ *
386
+ * Writing continues into the conversation that was opened rather than forking
387
+ * a new one, which is what makes "come back to this tomorrow" work at all.
388
+ */
389
+ const openConversation = (0, react_1.useCallback)((id, detail) => {
390
+ abortRef.current?.abort();
391
+ abortRef.current = null;
392
+ setIsBusy(false);
393
+ setError(null);
394
+ setTurns((0, ask_ai_turns_js_1.turnsFromStored)(detail.turns));
395
+ setConversationId(id);
396
+ setShowHistory(false);
397
+ }, []);
353
398
  const lastTurn = turns.at(-1);
354
399
  const isAnswering = isBusy && lastTurn?.role === "assistant";
355
400
  const rendered = (0, react_1.useMemo)(() => turns.map((turn) => {
356
401
  const isLive = isAnswering && turn.id === lastTurn?.id;
357
- const lastTextIndex = lastTextBlockIndex(turn.blocks);
402
+ const lastTextIndex = (0, ask_ai_turns_js_1.lastTextBlockIndex)(turn.blocks);
358
403
  return ((0, jsx_runtime_1.jsxs)("div", { className: `connect-ai-host rounded-md border border-border p-2.5 text-sm ${turn.role === "user" ? "bg-surface-secondary" : "bg-accent/5"}`, children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: turn.role === "user" ? "You" : assistantName }), turn.role === "assistant" && ((0, jsx_runtime_1.jsx)(ThinkingDisclosure, { text: turn.reasoning, isStreaming: isLive && lastTextIndex === -1 })), turn.blocks.map((block, index) => block.kind === "text" ? (
359
404
  // The user's own turn is shown as they typed it - running a
360
405
  // question that happens to start with `#` through the markdown
@@ -371,9 +416,34 @@ function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placehold
371
416
  if (!text || isBusy)
372
417
  return;
373
418
  setInput("");
419
+ // Typing sends you back to the conversation: staying on the history list
420
+ // while an answer streams behind it would hide the thing just asked for.
421
+ setShowHistory(false);
374
422
  void send(text);
375
423
  };
376
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [!isOpen && ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-5" }) })), (0, jsx_runtime_1.jsx)("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${panelShellWidth(isOpen)} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width]`, children: (0, jsx_runtime_1.jsxs)("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-4 shrink-0 text-accent" }), (0, jsx_runtime_1.jsx)("span", { className: "flex-1 text-sm font-semibold", children: title }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "secondary", className: "h-auto rounded-full px-2.5 py-1 text-start text-xs whitespace-normal", isDisabled: isBusy, onPress: () => void send(suggestion), children: suggestion }, suggestion))) })) : null] })), rendered, isBusy && lastTurn?.role === "user" && ((0, jsx_runtime_1.jsxs)("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), (0, jsx_runtime_1.jsx)(ThinkingShimmer, {})] })), error && (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-danger", children: error }), (0, jsx_runtime_1.jsx)("div", { ref: bottomRef })] }), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", className: "flex-1", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "sr-only", children: placeholder }), (0, jsx_runtime_1.jsx)(react_2.Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Square, { className: "size-3.5" }) })) : ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { className: "size-3.5" }) }))] })] }) })] }));
424
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [!isOpen && ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: (0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-5" }) })), (0, jsx_runtime_1.jsx)("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${panelShellWidth(isOpen)} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width]`, children: (0, jsx_runtime_1.jsxs)("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Sparkles, { className: "size-4 shrink-0 text-accent" }), (0, jsx_runtime_1.jsx)("span", { className: "flex-1 text-sm font-semibold", children: title }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "New conversation",
425
+ // Disabled on an empty one rather than hidden: a control that
426
+ // appears and disappears as you use the panel is harder to find
427
+ // than one that is always in the same place.
428
+ isDisabled: turns.length === 0, onPress: startNewConversation, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": showHistory ? "Hide past conversations" : "Past conversations", "aria-pressed": showHistory, onPress: () => setShowHistory((open) => !open), children: (0, jsx_runtime_1.jsx)(lucide_react_1.History, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { className: "size-4" }) })] }), showHistory ? ((0, jsx_runtime_1.jsx)("div", { className: "min-h-0 flex-1 overflow-y-auto", children: (0, jsx_runtime_1.jsx)(ask_ai_history_js_1.AskAiHistory, { refreshKey: writeCount, activeConversationId: conversationId, onOpenConversation: openConversation }) })) : ((0, jsx_runtime_1.jsxs)("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && ((0, jsx_runtime_1.jsxs)("div", { className: "space-y-3", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? ((0, jsx_runtime_1.jsx)("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => ((0, jsx_runtime_1.jsx)(react_2.Button, { size: "sm", variant: "secondary", className: "h-auto rounded-full px-2.5 py-1 text-start text-xs whitespace-normal", isDisabled: isBusy, onPress: () => void send(suggestion), children: suggestion }, suggestion))) })) : null] })), rendered, isBusy && lastTurn?.role === "user" && ((0, jsx_runtime_1.jsxs)("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), (0, jsx_runtime_1.jsx)(ThinkingShimmer, {})] })), error && (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-danger", children: error }), (0, jsx_runtime_1.jsx)("div", { ref: bottomRef })] })), (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", className: "flex-1", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { className: "sr-only", children: placeholder }), (0, jsx_runtime_1.jsx)(react_2.Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Square, { className: "size-3.5" }) })) : ((0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Send, { className: "size-3.5" }) }))] })] }) })] }));
377
429
  }
378
430
  /** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
379
431
  const LINK_SAFETY = { enabled: false };
432
+ /**
433
+ * A fresh conversation id.
434
+ *
435
+ * `crypto.randomUUID` where it exists, and it usually does - but it is gated on
436
+ * a *secure context*, so it is simply absent on an `http://` origin that is not
437
+ * localhost. That is somebody's LAN dev server or a staging box on a bare IP,
438
+ * and a panel that throws there rather than chatting would be a poor trade for
439
+ * four lines. The fallback is not cryptographically strong and does not need to
440
+ * be: the id selects among the caller's own conversations and grants nothing -
441
+ * Connect refuses one belonging to another user or another application - so its
442
+ * only real job is not to collide with itself.
443
+ */
444
+ function newConversationId() {
445
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
446
+ return crypto.randomUUID();
447
+ }
448
+ return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
449
+ }
@@ -108,9 +108,9 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
108
108
  cache.current.set(key, pending);
109
109
  return pending;
110
110
  }, [basePath, fetchImpl]);
111
- const mutateResource = (0, react_1.useCallback)(async (path, body) => {
111
+ const mutateResource = (0, react_1.useCallback)(async (path, body, method = "POST") => {
112
112
  const response = await (fetchImpl ?? globalThis.fetch)(`${basePath}${path}`, {
113
- method: "POST",
113
+ method,
114
114
  credentials: "same-origin",
115
115
  headers: {
116
116
  accept: "application/json",
@@ -11,6 +11,7 @@ const scheduling_js_1 = require("./resources/scheduling.js");
11
11
  const contacts_js_1 = require("./resources/contacts.js");
12
12
  const channels_js_1 = require("./resources/channels.js");
13
13
  const calls_js_1 = require("./resources/calls.js");
14
+ const conversations_js_1 = require("./resources/conversations.js");
14
15
  const kv_js_1 = require("./resources/kv.js");
15
16
  const billing_js_1 = require("./resources/billing.js");
16
17
  const drive_js_1 = require("./resources/drive.js");
@@ -92,6 +93,8 @@ class ConnectClient {
92
93
  channels;
93
94
  /** The calls that arrived on those channels, with their transcripts. */
94
95
  calls;
96
+ /** The message threads a subscribing workspace's agents are holding. */
97
+ conversations;
95
98
  /** RAG chat and structured extraction. */
96
99
  ai;
97
100
  enrichment;
@@ -145,6 +148,7 @@ class ConnectClient {
145
148
  this.contacts = new contacts_js_1.ContactsResource(transport);
146
149
  this.channels = new channels_js_1.ChannelsResource(transport);
147
150
  this.calls = new calls_js_1.CallsResource(transport);
151
+ this.conversations = new conversations_js_1.ConversationsResource(transport);
148
152
  this.ai = new ai_js_1.AiResource(transport);
149
153
  this.enrichment = new enrichment_js_1.EnrichmentResource(transport);
150
154
  this.data = new data_js_1.DataResource(transport);
@@ -161,4 +165,4 @@ class ConnectClient {
161
165
  }
162
166
  exports.ConnectClient = ConnectClient;
163
167
  /** Kept in step with `package.json` by `sdk.test.ts`. */
164
- exports.SDK_VERSION = "0.28.0";
168
+ exports.SDK_VERSION = "0.30.0";
@@ -391,6 +391,12 @@ function createConnectHandler(options) {
391
391
  // decision, so there is nothing for this layer to check more strictly.
392
392
  screenContext: body.screenContext,
393
393
  screens: body.screens,
394
+ // The browser's own id for this conversation, so the turn is stored
395
+ // and can be reopened. Safe to relay: it decides *which* of the
396
+ // caller's own conversations is written to, never whose - Connect
397
+ // refuses an id belonging to another user or another application, and
398
+ // the owner comes from `asUser` below.
399
+ ...(typeof body.conversationId === "string" ? { conversationId: body.conversationId } : {}),
394
400
  // Connect re-derives the workspace from this token, so the line above
395
401
  // is belt to its braces - the browser cannot reach another tenant's
396
402
  // Context Index even if this route were changed to trust the request.
@@ -409,6 +415,75 @@ function createConnectHandler(options) {
409
415
  },
410
416
  });
411
417
  },
418
+ /**
419
+ * Ask AI history - the list, one transcript, and a deletion.
420
+ *
421
+ * The same rule as every other route: the user comes from the caller's own
422
+ * session, forwarded as `asUser`, and Connect reads both the person and
423
+ * the workspace from it. Nothing here takes an owner from the request, so
424
+ * a browser cannot page through anybody else's conversations - which
425
+ * matters more here than on most routes, because a transcript is the most
426
+ * candid record this API holds of what somebody was trying to do.
427
+ *
428
+ * The id is the only thing a browser supplies. It selects among the
429
+ * caller's own conversations and nothing more: an id belonging to another
430
+ * user is a 404, and one belonging to another application the same, so a
431
+ * shared workspace's two products cannot read each other's.
432
+ *
433
+ * It travels as `?id=` rather than as a path segment, and the store routes
434
+ * are why. This table is keyed by an exact `METHOD /path` string; the only
435
+ * thing that matches a *prefix* is `isStoreRoute`, kept to a closed list
436
+ * precisely so the bridge cannot drift into a generic proxy. Widening that
437
+ * matcher for one more resource would be the first step of exactly that
438
+ * drift, so the id goes in the query and the route keys stay exact.
439
+ *
440
+ * `requireToken` rather than `requireWorkspace`: Connect resolves the
441
+ * workspace from the token itself and refuses a staff session there, so
442
+ * the extra identity round trip would buy a differently-worded 403 and
443
+ * nothing else.
444
+ */
445
+ "GET /ai/conversations": async (request) => {
446
+ const url = new URL(request.url);
447
+ const limit = url.searchParams.get("limit");
448
+ const cursor = url.searchParams.get("cursor");
449
+ return json(await client.ai.listConversations({
450
+ asUser: await requireToken(request),
451
+ // Forwarded only when present, so Connect's own defaults apply
452
+ // rather than this layer inventing a second set of them.
453
+ ...(limit !== null ? { limit: Number(limit) } : {}),
454
+ ...(cursor ? { cursor } : {}),
455
+ }));
456
+ },
457
+ "GET /ai/conversation": async (request) => {
458
+ const conversationId = new URL(request.url).searchParams.get("id");
459
+ if (!conversationId) {
460
+ throw new errors_js_1.ConnectError({
461
+ status: 400,
462
+ code: "validation_error",
463
+ message: "id is required",
464
+ endpoint: "GET /ai/conversation",
465
+ });
466
+ }
467
+ return json(await client.ai.getConversation({
468
+ conversationId,
469
+ asUser: await requireToken(request),
470
+ }));
471
+ },
472
+ "DELETE /ai/conversation": async (request) => {
473
+ const conversationId = new URL(request.url).searchParams.get("id");
474
+ if (!conversationId) {
475
+ throw new errors_js_1.ConnectError({
476
+ status: 400,
477
+ code: "validation_error",
478
+ message: "id is required",
479
+ endpoint: "DELETE /ai/conversation",
480
+ });
481
+ }
482
+ return json(await client.ai.deleteConversation({
483
+ conversationId,
484
+ asUser: await requireToken(request),
485
+ }));
486
+ },
412
487
  /**
413
488
  * The store, forwarded.
414
489
  *