@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.
@@ -33,6 +33,10 @@ class AiResource {
33
33
  json: {
34
34
  workspaceId: input.workspaceId,
35
35
  messages: input.messages,
36
+ // Omitted when absent, which is how a caller opts out of history: no
37
+ // id, nothing stored. Connect also stores nothing for a call carrying
38
+ // no `asUser`, because a conversation belongs to a person.
39
+ ...(input.conversationId ? { conversationId: input.conversationId } : {}),
36
40
  // Omitted entirely when absent, rather than sent as `undefined` - an
37
41
  // application that describes no screens should send no field.
38
42
  ...(input.screens ? { screens: input.screens } : {}),
@@ -43,6 +47,57 @@ class AiResource {
43
47
  signal: options.signal,
44
48
  });
45
49
  }
50
+ /**
51
+ * The signed-in user's past conversations in your application, most recent
52
+ * first.
53
+ *
54
+ * `asUser` is required rather than optional here, unlike on `chat`: the rows
55
+ * are scoped to a person, and there is no application-wide view of them to
56
+ * fall back to. Connect reads the user and the workspace from the token.
57
+ *
58
+ * Cursor-paginated with no total - the list is ordered by last use, which
59
+ * moves while it is being read.
60
+ */
61
+ listConversations(input) {
62
+ const query = new URLSearchParams();
63
+ if (input.limit !== undefined)
64
+ query.set("limit", String(input.limit));
65
+ if (input.cursor)
66
+ query.set("cursor", input.cursor);
67
+ const suffix = query.size > 0 ? `?${query.toString()}` : "";
68
+ return (0, http_js_1.request)(this.transport, {
69
+ method: "GET",
70
+ path: `/ai/conversations${suffix}`,
71
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
72
+ });
73
+ }
74
+ /**
75
+ * One stored transcript, oldest turn first.
76
+ *
77
+ * `turns[].parts` comes back exactly as the model produced it - prose,
78
+ * reasoning and tool calls in order - so it can be handed straight back to a
79
+ * chat UI. Not this user's, or not your application's, is a `404` either way.
80
+ */
81
+ getConversation(input) {
82
+ return (0, http_js_1.request)(this.transport, {
83
+ method: "GET",
84
+ path: `/ai/conversations/${encodeURIComponent(input.conversationId)}`,
85
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
86
+ });
87
+ }
88
+ /**
89
+ * Deletes a conversation and every turn beneath it.
90
+ *
91
+ * Idempotent - one already gone answers `{ deleted: true }`, so a retry after
92
+ * a dropped response does not read as a failure.
93
+ */
94
+ deleteConversation(input) {
95
+ return (0, http_js_1.request)(this.transport, {
96
+ method: "DELETE",
97
+ path: `/ai/conversations/${encodeURIComponent(input.conversationId)}`,
98
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
99
+ });
100
+ }
46
101
  /** Extract structured JSON from a file or image URL, against a platform extraction schema referenced by id. */
47
102
  async extract(input) {
48
103
  const result = await (0, http_js_1.request)(this.transport, {
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConversationsResource = void 0;
4
+ const http_js_1 = require("../http.js");
5
+ const acting_as_js_1 = require("./acting-as.js");
6
+ /**
7
+ * What a workspace's agents are saying in writing.
8
+ *
9
+ * **The sibling of `calls`, with one difference that decides how it is
10
+ * gated: these are the *workspace's* threads, not your application's.** A call
11
+ * arrived on a channel you hold, so `calls.list` is scoped to your
12
+ * application. A conversation lives in the workspace's own subtree and belongs
13
+ * to it the way its contact book does - so reading one needs a live
14
+ * subscription in that workspace, and, when the call comes from a browser
15
+ * through the bridge, the signed-in member's own `agent:read`.
16
+ *
17
+ * Read-only, and deliberately. Sending on a thread is not a missing verb:
18
+ * inside WhatsApp's 24-hour window a reply is the agent's to make, outside it
19
+ * only an approved template may be sent, and posting into a thread would mean
20
+ * saying which of those it was and what it means for the agent still
21
+ * answering.
22
+ */
23
+ class ConversationsResource {
24
+ transport;
25
+ constructor(transport) {
26
+ this.transport = transport;
27
+ }
28
+ /**
29
+ * The workspace's threads, newest activity first.
30
+ *
31
+ * **Page until `hasMore` is `false`; do not stop on an empty `items`.** An
32
+ * inbox grows without bound, so there is no total count to page against.
33
+ */
34
+ list(input) {
35
+ return (0, http_js_1.request)(this.transport, {
36
+ method: "GET",
37
+ path: "/conversations",
38
+ query: {
39
+ workspaceId: input.workspaceId,
40
+ contactAddress: input.contactAddress,
41
+ status: input.status,
42
+ cursor: input.cursor,
43
+ limit: input.limit === undefined ? undefined : String(input.limit),
44
+ },
45
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
46
+ });
47
+ }
48
+ /**
49
+ * One thread and its recent messages, oldest first.
50
+ *
51
+ * **Capped, not paged** - you get the same window the agent itself sees when
52
+ * it composes a reply, so you are never shown less context than it used.
53
+ *
54
+ * Turns that produced no message are included and carry `failure`. Render
55
+ * them: a transcript that silently drops them reads as a customer being
56
+ * ignored, which is the opposite of what happened.
57
+ *
58
+ * Throws `ConnectError` with `code: "not_found"` for a thread this workspace
59
+ * does not hold.
60
+ */
61
+ get(conversationId, options) {
62
+ return (0, http_js_1.request)(this.transport, {
63
+ method: "GET",
64
+ path: `/conversations/${encodeURIComponent(conversationId)}`,
65
+ query: { workspaceId: options.workspaceId },
66
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
67
+ });
68
+ }
69
+ }
70
+ exports.ConversationsResource = ConversationsResource;
@@ -0,0 +1,103 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState } from "react";
4
+ import { Button } from "@heroui/react";
5
+ import { MessageSquare, Trash2 } from "lucide-react";
6
+ import { useConnect } from "../provider.js";
7
+ /** Bridge paths, spelled once - the list is read and invalidated from three places. */
8
+ const LIST_PATH = "/ai/conversations";
9
+ const detailPath = (id) => `/ai/conversation?id=${encodeURIComponent(id)}`;
10
+ /**
11
+ * Past Ask AI conversations, for reopening or deleting.
12
+ *
13
+ * ## It fetches only while it is open
14
+ *
15
+ * `<ConnectAskAi>` is mounted for the whole application shell, so an
16
+ * unconditional read here would put a request on every page load to populate a
17
+ * list most people never open. It reads on open, and again whenever the panel
18
+ * has written a new conversation since - which is what `refreshKey` is for.
19
+ *
20
+ * ## Deletion confirms inline, not in a dialog
21
+ *
22
+ * Destructive actions confirm; this one does it at the weight it deserves.
23
+ * Losing a transcript is recoverable by asking again, not a cancelled
24
+ * subscription, and a modal over a 400px panel would be heavier than the thing
25
+ * it guards. The row swaps to "Delete? / Cancel" and stays there until
26
+ * answered.
27
+ */
28
+ export function AskAiHistory({ refreshKey, activeConversationId, onOpenConversation, }) {
29
+ const { fetchResource, mutateResource, invalidateResource } = useConnect();
30
+ const [conversations, setConversations] = useState(null);
31
+ const [error, setError] = useState(null);
32
+ const [confirmingId, setConfirmingId] = useState(null);
33
+ const [busyId, setBusyId] = useState(null);
34
+ useEffect(() => {
35
+ let active = true;
36
+ // The cached entry is dropped first. This component mounts only when the
37
+ // user asks for the list, and the provider's cache would otherwise hand
38
+ // them the copy taken before the last three questions they asked.
39
+ invalidateResource(LIST_PATH);
40
+ fetchResource(LIST_PATH)
41
+ .then((page) => {
42
+ if (!active)
43
+ return;
44
+ setConversations(page.conversations);
45
+ setError(null);
46
+ })
47
+ .catch((cause) => {
48
+ if (!active)
49
+ return;
50
+ // An empty list rather than a permanent skeleton: the error is shown
51
+ // beside it, and a panel stuck loading says nothing at all.
52
+ setConversations([]);
53
+ setError(cause instanceof Error ? cause.message : "Could not load your conversations");
54
+ });
55
+ return () => {
56
+ active = false;
57
+ };
58
+ }, [fetchResource, invalidateResource, refreshKey]);
59
+ const open = async (id) => {
60
+ setBusyId(id);
61
+ try {
62
+ const detail = await fetchResource(detailPath(id));
63
+ onOpenConversation(id, detail);
64
+ }
65
+ catch (cause) {
66
+ setError(cause instanceof Error ? cause.message : "Could not open that conversation");
67
+ }
68
+ finally {
69
+ setBusyId(null);
70
+ }
71
+ };
72
+ const remove = async (id) => {
73
+ setBusyId(id);
74
+ // Removed from the list before the response lands. The confirmation was the
75
+ // decision point, and a row that lingers while the request flies reads as a
76
+ // press that did not take - so the list is patched and put back on failure.
77
+ const previous = conversations;
78
+ setConversations((current) => current?.filter((row) => row.id !== id) ?? null);
79
+ setConfirmingId(null);
80
+ try {
81
+ await mutateResource(detailPath(id), undefined, "DELETE");
82
+ invalidateResource(LIST_PATH);
83
+ }
84
+ catch (cause) {
85
+ setConversations(previous);
86
+ setError(cause instanceof Error ? cause.message : "Could not delete that conversation");
87
+ }
88
+ finally {
89
+ setBusyId(null);
90
+ }
91
+ };
92
+ if (conversations === null) {
93
+ return (_jsx("div", { className: "space-y-2 p-4", "aria-busy": "true", children: [0, 1, 2].map((row) => (_jsx("div", { className: "h-9 animate-pulse rounded-md bg-surface-secondary" }, row))) }));
94
+ }
95
+ if (conversations.length === 0) {
96
+ return (_jsx("div", { className: "p-4", children: error ? (_jsx("p", { className: "text-xs text-danger", children: error })) : (_jsx("p", { className: "text-center text-xs text-muted", children: "Nothing here yet. Conversations you have are saved automatically." })) }));
97
+ }
98
+ return (_jsxs("div", { className: "p-2", children: [error && _jsx("p", { className: "px-2 pb-2 text-xs text-danger", children: error }), _jsx("ul", { className: "space-y-0.5", children: conversations.map((conversation) => {
99
+ const isConfirming = confirmingId === conversation.id;
100
+ const isActive = conversation.id === activeConversationId;
101
+ return (_jsx("li", { children: isConfirming ? (_jsxs("div", { className: "flex items-center gap-2 rounded-md border border-border px-2 py-1.5", children: [_jsx("span", { className: "flex-1 truncate text-xs text-muted", children: "Delete this conversation?" }), _jsx(Button, { size: "sm", variant: "danger", className: "h-7 px-2 text-xs", isDisabled: busyId === conversation.id, onPress: () => void remove(conversation.id), children: "Delete" }), _jsx(Button, { size: "sm", variant: "tertiary", className: "h-7 px-2 text-xs", onPress: () => setConfirmingId(null), children: "Cancel" })] })) : (_jsxs("div", { className: `group flex items-center gap-1 rounded-md ${isActive ? "bg-surface-secondary" : ""}`, children: [_jsxs(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: [_jsx(MessageSquare, { className: "size-3.5 shrink-0 text-muted" }), _jsx("span", { className: "min-w-0 flex-1 truncate text-xs font-normal", children: conversation.title })] }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "ghost", className: "shrink-0", "aria-label": `Delete "${conversation.title}"`, onPress: () => setConfirmingId(conversation.id), children: _jsx(Trash2, { className: "size-3.5 text-muted" }) })] })) }, conversation.id));
102
+ }) })] }));
103
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Index of the final text block, or `-1`. A reverse loop rather than
3
+ * `findLastIndex`, which is ES2023 - this package targets ES2022 so its
4
+ * published output stays usable on the runtimes its `engines` field claims.
5
+ */
6
+ export function lastTextBlockIndex(blocks) {
7
+ for (let index = blocks.length - 1; index >= 0; index--) {
8
+ if (blocks[index]?.kind === "text")
9
+ return index;
10
+ }
11
+ return -1;
12
+ }
13
+ /** Appends a text delta to the open text block, or opens one after a widget. */
14
+ export function appendText(blocks, text) {
15
+ const last = blocks.at(-1);
16
+ if (last?.kind === "text") {
17
+ return [...blocks.slice(0, -1), { kind: "text", text: last.text + text }];
18
+ }
19
+ return [...blocks, { kind: "text", text }];
20
+ }
21
+ /**
22
+ * Rebuilds the panel's turns from a stored transcript.
23
+ *
24
+ * The inverse of what the stream reader does live, and it has to agree with it
25
+ * or a reopened conversation renders differently from the one the user just
26
+ * had. Two shapes, one source: the stream delivers `text-delta`,
27
+ * `reasoning-delta` and `tool-input-available` frames, and storage keeps the
28
+ * settled `UIMessage` parts those frames built - `{ type: "text" }`,
29
+ * `{ type: "reasoning" }`, and `{ type: "tool-<name>" }` carrying the input the
30
+ * widget was drawn from.
31
+ *
32
+ * Three rules, each of which is a thing that would otherwise render wrong:
33
+ *
34
+ * - **A tool part with no input is dropped, not restored.** `input: null` means
35
+ * "still arriving" to the renderer, which draws a skeleton - correct live,
36
+ * and a shimmer that never resolves on a transcript that finished days ago.
37
+ * - **Reasoning is hoisted per turn**, not left in place. It is rendered above
38
+ * the answer in a disclosure, which is where it sat while streaming.
39
+ * - **A part this build cannot place is skipped rather than guessed at.**
40
+ * `parts` is the AI SDK's own shape and gains kinds over time; an unknown one
41
+ * costs a widget, and inventing a rendering for it costs the whole turn.
42
+ */
43
+ export function turnsFromStored(turns) {
44
+ return turns.map((turn) => {
45
+ let blocks = [];
46
+ let reasoning = "";
47
+ for (const raw of turn.parts) {
48
+ const part = raw;
49
+ if (typeof part?.type !== "string")
50
+ continue;
51
+ if (part.type === "text") {
52
+ if (typeof part.text === "string" && part.text.length > 0) {
53
+ blocks = appendText(blocks, part.text);
54
+ }
55
+ continue;
56
+ }
57
+ if (part.type === "reasoning") {
58
+ if (typeof part.text === "string")
59
+ reasoning += part.text;
60
+ continue;
61
+ }
62
+ if (part.type.startsWith("tool-")) {
63
+ // See the note above: an absent input is a skeleton forever.
64
+ if (part.input === undefined || part.input === null)
65
+ continue;
66
+ blocks = [
67
+ ...blocks,
68
+ {
69
+ kind: "tool",
70
+ // The stored call id when there is one. Falling back to the block's
71
+ // position keeps React keys unique within the turn, which is all
72
+ // this id is used for once the turn is settled.
73
+ id: typeof part.toolCallId === "string" ? part.toolCallId : `${turn.id}-${blocks.length}`,
74
+ name: part.type.slice("tool-".length),
75
+ input: part.input,
76
+ },
77
+ ];
78
+ }
79
+ }
80
+ return { id: turn.id, role: turn.role, blocks, reasoning };
81
+ });
82
+ }
@@ -2,12 +2,14 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { Button, Input, Label, TextField } from "@heroui/react";
5
- import { Brain, ChevronDown, Send, Sparkles, Square, X } from "lucide-react";
5
+ import { Brain, ChevronDown, History, Plus, Send, Sparkles, Square, X } from "lucide-react";
6
6
  import { Streamdown } from "streamdown";
7
7
  import { useConnect } from "../provider.js";
8
8
  import { useActivePath } from "../use-active-path.js";
9
9
  import { matchConnectScreen } from "./ai-screens.js";
10
10
  import { AiActions, AiChart, AiDetails, AiMetrics, AiSources, AiSteps, AiTable, AiTimeline } from "./ai-widgets.js";
11
+ import { AskAiHistory } from "./ask-ai-history.js";
12
+ import { appendText, lastTextBlockIndex, turnsFromStored, } from "./ask-ai-turns.js";
11
13
  /**
12
14
  * The panel's open width. The *inner* pane always carries it, so the content
13
15
  * keeps its layout while the outer shell's width animates to zero around it.
@@ -167,26 +169,6 @@ async function readStream(response, onChunk) {
167
169
  }
168
170
  }
169
171
  }
170
- /**
171
- * Index of the final text block, or `-1`. A reverse loop rather than
172
- * `findLastIndex`, which is ES2023 - this package targets ES2022 so its
173
- * published output stays usable on the runtimes its `engines` field claims.
174
- */
175
- function lastTextBlockIndex(blocks) {
176
- for (let index = blocks.length - 1; index >= 0; index--) {
177
- if (blocks[index]?.kind === "text")
178
- return index;
179
- }
180
- return -1;
181
- }
182
- /** Appends a text delta to the open text block, or opens one after a widget. */
183
- function appendText(blocks, text) {
184
- const last = blocks.at(-1);
185
- if (last?.kind === "text") {
186
- return [...blocks.slice(0, -1), { kind: "text", text: last.text + text }];
187
- }
188
- return [...blocks, { kind: "text", text }];
189
- }
190
172
  /**
191
173
  * Ask AI - a chat panel grounded in the signed-in workspace's Context Index,
192
174
  * streamed through your own bridge.
@@ -235,6 +217,31 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
235
217
  const [input, setInput] = useState("");
236
218
  const [isBusy, setIsBusy] = useState(false);
237
219
  const [error, setError] = useState(null);
220
+ const [showHistory, setShowHistory] = useState(false);
221
+ /**
222
+ * The conversation being written to.
223
+ *
224
+ * Generated here, before the first message, because the server cannot hand
225
+ * one back: it is streaming a response, and there is no point in that stream
226
+ * at which a new id could reach the browser ahead of the first token. So the
227
+ * client picks it and Connect upserts - safe because ownership is checked
228
+ * rather than assumed, and an id naming somebody else's conversation is
229
+ * refused.
230
+ *
231
+ * Lazily initialised rather than computed in a `useMemo`: this has to survive
232
+ * re-renders and change only when the user starts a new conversation, which
233
+ * is precisely what `useMemo` does not promise.
234
+ */
235
+ const [conversationId, setConversationId] = useState(() => newConversationId());
236
+ /**
237
+ * Bumped whenever a turn has been written, so the history list re-reads the
238
+ * next time it is opened.
239
+ *
240
+ * A counter rather than a refetch call, because the list is unmounted while
241
+ * closed - there is nothing to tell. It is *why* the list re-reads, not the
242
+ * list itself.
243
+ */
244
+ const [writeCount, setWriteCount] = useState(0);
238
245
  const abortRef = useRef(null);
239
246
  const bottomRef = useRef(null);
240
247
  // Ids only have to be unique within one mounted conversation, so a counter is
@@ -309,6 +316,11 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
309
316
  // decide what anyone may do. See `ConnectScreen`.
310
317
  ...(resolvedPath ? { screenContext: { pathname: resolvedPath } } : {}),
311
318
  ...(screens.length > 0 ? { screens } : {}),
319
+ // What makes the conversation resumable. Connect stores the turn
320
+ // against it and `<AskAiHistory>` reads it back; a turn sent
321
+ // without one is answered exactly the same way and remembered by
322
+ // nobody.
323
+ conversationId,
312
324
  }, { signal: controller.signal });
313
325
  // The assistant's turn is appended only once the stream is live, so a
314
326
  // failed request leaves an error under the question rather than an
@@ -345,8 +357,41 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
345
357
  if (abortRef.current === controller)
346
358
  abortRef.current = null;
347
359
  setIsBusy(false);
360
+ // Even on an aborted or failed turn: the user's own question has
361
+ // already been persisted by the time anything can go wrong downstream
362
+ // of it, so the list is stale either way.
363
+ setWriteCount((count) => count + 1);
348
364
  }
349
- }, [streamResource, turns]);
365
+ }, [streamResource, turns, conversationId]);
366
+ /**
367
+ * Clears the transcript and starts writing into a fresh conversation.
368
+ *
369
+ * The old one is not deleted - it is in History, which is the whole point.
370
+ */
371
+ const startNewConversation = useCallback(() => {
372
+ abortRef.current?.abort();
373
+ abortRef.current = null;
374
+ setIsBusy(false);
375
+ setTurns([]);
376
+ setError(null);
377
+ setShowHistory(false);
378
+ setConversationId(newConversationId());
379
+ }, []);
380
+ /**
381
+ * Replaces the transcript with a stored one, and continues writing into it.
382
+ *
383
+ * Writing continues into the conversation that was opened rather than forking
384
+ * a new one, which is what makes "come back to this tomorrow" work at all.
385
+ */
386
+ const openConversation = useCallback((id, detail) => {
387
+ abortRef.current?.abort();
388
+ abortRef.current = null;
389
+ setIsBusy(false);
390
+ setError(null);
391
+ setTurns(turnsFromStored(detail.turns));
392
+ setConversationId(id);
393
+ setShowHistory(false);
394
+ }, []);
350
395
  const lastTurn = turns.at(-1);
351
396
  const isAnswering = isBusy && lastTurn?.role === "assistant";
352
397
  const rendered = useMemo(() => turns.map((turn) => {
@@ -368,9 +413,34 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
368
413
  if (!text || isBusy)
369
414
  return;
370
415
  setInput("");
416
+ // Typing sends you back to the conversation: staying on the history list
417
+ // while an answer streams behind it would hide the thing just asked for.
418
+ setShowHistory(false);
371
419
  void send(text);
372
420
  };
373
- return (_jsxs(_Fragment, { children: [!isOpen && (_jsx(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: _jsx(Sparkles, { className: "size-5" }) })), _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" }), _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: _jsxs("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [_jsx(Sparkles, { className: "size-4 shrink-0 text-accent" }), _jsx("span", { className: "flex-1 text-sm font-semibold", children: title }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: _jsx(X, { className: "size-4" }) })] }), _jsxs("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && (_jsxs("div", { className: "space-y-3", children: [_jsx("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? (_jsx("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => (_jsx(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" && (_jsxs("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), _jsx(ThinkingShimmer, {})] })), error && _jsx("p", { className: "text-xs text-danger", children: error }), _jsx("div", { ref: bottomRef })] }), _jsxs("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [_jsxs(TextField, { variant: "secondary", className: "flex-1", children: [_jsx(Label, { className: "sr-only", children: placeholder }), _jsx(Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? (_jsx(Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: _jsx(Square, { className: "size-3.5" }) })) : (_jsx(Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: _jsx(Send, { className: "size-3.5" }) }))] })] }) })] }));
421
+ return (_jsxs(_Fragment, { children: [!isOpen && (_jsx(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: _jsx(Sparkles, { className: "size-5" }) })), _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" }), _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: _jsxs("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [_jsx(Sparkles, { className: "size-4 shrink-0 text-accent" }), _jsx("span", { className: "flex-1 text-sm font-semibold", children: title }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "New conversation",
422
+ // Disabled on an empty one rather than hidden: a control that
423
+ // appears and disappears as you use the panel is harder to find
424
+ // than one that is always in the same place.
425
+ isDisabled: turns.length === 0, onPress: startNewConversation, children: _jsx(Plus, { className: "size-4" }) }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": showHistory ? "Hide past conversations" : "Past conversations", "aria-pressed": showHistory, onPress: () => setShowHistory((open) => !open), children: _jsx(History, { className: "size-4" }) }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: _jsx(X, { className: "size-4" }) })] }), showHistory ? (_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(AskAiHistory, { refreshKey: writeCount, activeConversationId: conversationId, onOpenConversation: openConversation }) })) : (_jsxs("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && (_jsxs("div", { className: "space-y-3", children: [_jsx("p", { className: "text-sm text-muted", children: hint }), currentScreen?.suggestions?.length ? (_jsx("div", { className: "flex flex-wrap gap-1.5", children: currentScreen.suggestions.map((suggestion) => (_jsx(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" && (_jsxs("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), _jsx(ThinkingShimmer, {})] })), error && _jsx("p", { className: "text-xs text-danger", children: error }), _jsx("div", { ref: bottomRef })] })), _jsxs("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [_jsxs(TextField, { variant: "secondary", className: "flex-1", children: [_jsx(Label, { className: "sr-only", children: placeholder }), _jsx(Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? (_jsx(Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: _jsx(Square, { className: "size-3.5" }) })) : (_jsx(Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: _jsx(Send, { className: "size-3.5" }) }))] })] }) })] }));
374
426
  }
375
427
  /** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
376
428
  const LINK_SAFETY = { enabled: false };
429
+ /**
430
+ * A fresh conversation id.
431
+ *
432
+ * `crypto.randomUUID` where it exists, and it usually does - but it is gated on
433
+ * a *secure context*, so it is simply absent on an `http://` origin that is not
434
+ * localhost. That is somebody's LAN dev server or a staging box on a bare IP,
435
+ * and a panel that throws there rather than chatting would be a poor trade for
436
+ * four lines. The fallback is not cryptographically strong and does not need to
437
+ * be: the id selects among the caller's own conversations and grants nothing -
438
+ * Connect refuses one belonging to another user or another application - so its
439
+ * only real job is not to collide with itself.
440
+ */
441
+ function newConversationId() {
442
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
443
+ return crypto.randomUUID();
444
+ }
445
+ return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
446
+ }
@@ -89,9 +89,9 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
89
89
  cache.current.set(key, pending);
90
90
  return pending;
91
91
  }, [basePath, fetchImpl]);
92
- const mutateResource = useCallback(async (path, body) => {
92
+ const mutateResource = useCallback(async (path, body, method = "POST") => {
93
93
  const response = await (fetchImpl ?? globalThis.fetch)(`${basePath}${path}`, {
94
- method: "POST",
94
+ method,
95
95
  credentials: "same-origin",
96
96
  headers: {
97
97
  accept: "application/json",
@@ -8,6 +8,7 @@ import { SchedulingResource } from "./resources/scheduling.js";
8
8
  import { ContactsResource } from "./resources/contacts.js";
9
9
  import { ChannelsResource } from "./resources/channels.js";
10
10
  import { CallsResource } from "./resources/calls.js";
11
+ import { ConversationsResource } from "./resources/conversations.js";
11
12
  import { KvResource } from "./resources/kv.js";
12
13
  import { BillingResource } from "./resources/billing.js";
13
14
  import { DriveResource } from "./resources/drive.js";
@@ -89,6 +90,8 @@ export class ConnectClient {
89
90
  channels;
90
91
  /** The calls that arrived on those channels, with their transcripts. */
91
92
  calls;
93
+ /** The message threads a subscribing workspace's agents are holding. */
94
+ conversations;
92
95
  /** RAG chat and structured extraction. */
93
96
  ai;
94
97
  enrichment;
@@ -142,6 +145,7 @@ export class ConnectClient {
142
145
  this.contacts = new ContactsResource(transport);
143
146
  this.channels = new ChannelsResource(transport);
144
147
  this.calls = new CallsResource(transport);
148
+ this.conversations = new ConversationsResource(transport);
145
149
  this.ai = new AiResource(transport);
146
150
  this.enrichment = new EnrichmentResource(transport);
147
151
  this.data = new DataResource(transport);
@@ -157,4 +161,4 @@ export class ConnectClient {
157
161
  }
158
162
  }
159
163
  /** Kept in step with `package.json` by `sdk.test.ts`. */
160
- export const SDK_VERSION = "0.28.0";
164
+ export const SDK_VERSION = "0.30.0";
@@ -388,6 +388,12 @@ export function createConnectHandler(options) {
388
388
  // decision, so there is nothing for this layer to check more strictly.
389
389
  screenContext: body.screenContext,
390
390
  screens: body.screens,
391
+ // The browser's own id for this conversation, so the turn is stored
392
+ // and can be reopened. Safe to relay: it decides *which* of the
393
+ // caller's own conversations is written to, never whose - Connect
394
+ // refuses an id belonging to another user or another application, and
395
+ // the owner comes from `asUser` below.
396
+ ...(typeof body.conversationId === "string" ? { conversationId: body.conversationId } : {}),
391
397
  // Connect re-derives the workspace from this token, so the line above
392
398
  // is belt to its braces - the browser cannot reach another tenant's
393
399
  // Context Index even if this route were changed to trust the request.
@@ -406,6 +412,75 @@ export function createConnectHandler(options) {
406
412
  },
407
413
  });
408
414
  },
415
+ /**
416
+ * Ask AI history - the list, one transcript, and a deletion.
417
+ *
418
+ * The same rule as every other route: the user comes from the caller's own
419
+ * session, forwarded as `asUser`, and Connect reads both the person and
420
+ * the workspace from it. Nothing here takes an owner from the request, so
421
+ * a browser cannot page through anybody else's conversations - which
422
+ * matters more here than on most routes, because a transcript is the most
423
+ * candid record this API holds of what somebody was trying to do.
424
+ *
425
+ * The id is the only thing a browser supplies. It selects among the
426
+ * caller's own conversations and nothing more: an id belonging to another
427
+ * user is a 404, and one belonging to another application the same, so a
428
+ * shared workspace's two products cannot read each other's.
429
+ *
430
+ * It travels as `?id=` rather than as a path segment, and the store routes
431
+ * are why. This table is keyed by an exact `METHOD /path` string; the only
432
+ * thing that matches a *prefix* is `isStoreRoute`, kept to a closed list
433
+ * precisely so the bridge cannot drift into a generic proxy. Widening that
434
+ * matcher for one more resource would be the first step of exactly that
435
+ * drift, so the id goes in the query and the route keys stay exact.
436
+ *
437
+ * `requireToken` rather than `requireWorkspace`: Connect resolves the
438
+ * workspace from the token itself and refuses a staff session there, so
439
+ * the extra identity round trip would buy a differently-worded 403 and
440
+ * nothing else.
441
+ */
442
+ "GET /ai/conversations": async (request) => {
443
+ const url = new URL(request.url);
444
+ const limit = url.searchParams.get("limit");
445
+ const cursor = url.searchParams.get("cursor");
446
+ return json(await client.ai.listConversations({
447
+ asUser: await requireToken(request),
448
+ // Forwarded only when present, so Connect's own defaults apply
449
+ // rather than this layer inventing a second set of them.
450
+ ...(limit !== null ? { limit: Number(limit) } : {}),
451
+ ...(cursor ? { cursor } : {}),
452
+ }));
453
+ },
454
+ "GET /ai/conversation": async (request) => {
455
+ const conversationId = new URL(request.url).searchParams.get("id");
456
+ if (!conversationId) {
457
+ throw new ConnectError({
458
+ status: 400,
459
+ code: "validation_error",
460
+ message: "id is required",
461
+ endpoint: "GET /ai/conversation",
462
+ });
463
+ }
464
+ return json(await client.ai.getConversation({
465
+ conversationId,
466
+ asUser: await requireToken(request),
467
+ }));
468
+ },
469
+ "DELETE /ai/conversation": async (request) => {
470
+ const conversationId = new URL(request.url).searchParams.get("id");
471
+ if (!conversationId) {
472
+ throw new ConnectError({
473
+ status: 400,
474
+ code: "validation_error",
475
+ message: "id is required",
476
+ endpoint: "DELETE /ai/conversation",
477
+ });
478
+ }
479
+ return json(await client.ai.deleteConversation({
480
+ conversationId,
481
+ asUser: await requireToken(request),
482
+ }));
483
+ },
409
484
  /**
410
485
  * The store, forwarded.
411
486
  *