@subako-ai/assistant-ui 0.1.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/src/chat.tsx ADDED
@@ -0,0 +1,351 @@
1
+ "use client";
2
+
3
+ import type { ReasoningMessagePartProps, ToolCallMessagePartProps } from "@assistant-ui/react";
4
+ import {
5
+ AssistantRuntimeProvider,
6
+ ComposerPrimitive,
7
+ MessagePartPrimitive,
8
+ MessagePrimitive,
9
+ ThreadPrimitive,
10
+ } from "@assistant-ui/react";
11
+ import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
12
+ import { useSessionState } from "@subako-ai/react";
13
+ import type { ComponentProps, ReactNode } from "react";
14
+ import { createContext, useContext } from "react";
15
+ import remarkGfm from "remark-gfm";
16
+ import type { SubakoSession } from "./runtime.js";
17
+ import { useSubakoRuntime } from "./runtime.js";
18
+
19
+ /**
20
+ * Enough style to read a conversation with no setup at all. Every surface names
21
+ * its own colour and its own background, so the chat is legible on a page of any
22
+ * colour rather than inheriting half of one. The eight custom properties are the
23
+ * theming seam: light by default, dark when the browser asks for it, and an app
24
+ * that sets them on `.subako-chat` overrides both. `className` is where an app
25
+ * takes the rest over.
26
+ *
27
+ * Every rule sits in the `subako` cascade layer. A rule of the app's own that
28
+ * names a class therefore wins at any specificity and from anywhere in the
29
+ * page; an app whose CSS is layered itself names `subako` in its layer order,
30
+ * where it wants the chat's defaults to sit. The guard after the layer keeps a
31
+ * page's reset out of the chat's own elements.
32
+ */
33
+ const STYLE = `
34
+ @layer subako {
35
+ .subako-chat { --subako-bg: #ffffff; --subako-fg: #18181b; --subako-muted: #52525b; --subako-accent: #2563eb;
36
+ --subako-accent-fg: #ffffff; --subako-border: #d4d4d8; --subako-surface: #f4f4f5; --subako-error: #b91c1c;
37
+ color-scheme: light dark; display: flex; flex-direction: column; height: 100%; min-height: 0; gap: 8px;
38
+ font: 14px/1.5 system-ui, sans-serif; background: var(--subako-bg); color: var(--subako-fg); }
39
+ @media (prefers-color-scheme: dark) {
40
+ .subako-chat { --subako-bg: #18181b; --subako-fg: #f4f4f5; --subako-muted: #a1a1aa; --subako-accent: #3b82f6;
41
+ --subako-accent-fg: #ffffff; --subako-border: #3f3f46; --subako-surface: #27272a; --subako-error: #fca5a5; }
42
+ }
43
+ .subako-viewport { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column;
44
+ gap: 8px; padding: 8px; }
45
+ .subako-message { max-width: 42em; padding: 8px 12px; border-radius: 12px; white-space: normal;
46
+ overflow-wrap: anywhere; }
47
+ .subako-message p { margin: 0 0 4px; }
48
+ .subako-message p:last-child { margin-bottom: 0; }
49
+ .subako-user { align-self: flex-end; background: var(--subako-accent); color: var(--subako-accent-fg); }
50
+ .subako-assistant { align-self: flex-start; background: var(--subako-surface); color: var(--subako-fg); }
51
+ .subako-thinking { margin: 4px 0; padding: 4px 8px; border-left: 2px solid var(--subako-border);
52
+ color: var(--subako-muted); font-style: italic; white-space: pre-wrap; }
53
+ /* The assistant's markdown: enough type to read headings, lists, code and tables inside a bubble. */
54
+ .subako-markdown > :first-child { margin-top: 0; }
55
+ .subako-markdown > :last-child { margin-bottom: 0; }
56
+ .subako-markdown p, .subako-markdown ul, .subako-markdown ol, .subako-markdown pre, .subako-markdown blockquote,
57
+ .subako-markdown table { margin: 0 0 6px; }
58
+ .subako-markdown h1, .subako-markdown h2, .subako-markdown h3, .subako-markdown h4, .subako-markdown h5,
59
+ .subako-markdown h6 { margin: 8px 0 4px; font-size: 1em; font-weight: 600; line-height: 1.3; }
60
+ .subako-markdown h1 { font-size: 1.25em; }
61
+ .subako-markdown h2 { font-size: 1.15em; }
62
+ .subako-markdown h3 { font-size: 1.05em; }
63
+ .subako-markdown ul, .subako-markdown ol { padding-left: 1.5em; }
64
+ .subako-markdown ul { list-style: disc; }
65
+ .subako-markdown ol { list-style: decimal; }
66
+ .subako-markdown li { margin: 2px 0; }
67
+ .subako-markdown a { color: var(--subako-accent); text-decoration: underline; }
68
+ .subako-markdown code { padding: 1px 4px; border-radius: 4px; background: var(--subako-bg);
69
+ font-family: ui-monospace, monospace; font-size: 12px; }
70
+ .subako-markdown pre { padding: 6px 8px; border-radius: 6px; background: var(--subako-bg); white-space: pre-wrap;
71
+ overflow-wrap: anywhere; }
72
+ .subako-markdown pre code { padding: 0; background: none; }
73
+ .subako-markdown blockquote { padding: 0 0 0 8px; border-left: 2px solid var(--subako-border);
74
+ color: var(--subako-muted); }
75
+ .subako-markdown table { border-collapse: collapse; font-size: 13px; }
76
+ .subako-markdown th, .subako-markdown td { padding: 2px 6px; border: 1px solid var(--subako-border);
77
+ text-align: left; }
78
+ .subako-markdown hr { margin: 8px 0; border: 0; border-top: 1px solid var(--subako-border); }
79
+ .subako-markdown img { max-width: 100%; height: auto; border-radius: 6px; }
80
+ .subako-writing { display: block; margin-top: 4px; color: var(--subako-muted); font-size: 10px; }
81
+ .subako-tool { margin: 4px 0; padding: 6px 8px; border: 1px solid var(--subako-border); border-radius: 8px;
82
+ background: var(--subako-bg); color: var(--subako-fg); }
83
+ /* The row is the fold's handle: the name, the status, and a chevron that turns when it is open. */
84
+ .subako-tool-summary { display: flex; align-items: center; gap: 8px; cursor: pointer; list-style: none; }
85
+ .subako-tool-summary::-webkit-details-marker { display: none; }
86
+ .subako-tool-summary::after { content: "›"; margin-left: auto; color: var(--subako-muted); font-size: 16px;
87
+ line-height: 1; transition: transform 0.15s; }
88
+ .subako-tool-fold[open] > .subako-tool-summary::after { transform: rotate(90deg); }
89
+ .subako-tool-name { font-weight: 600; font-family: ui-monospace, monospace; }
90
+ .subako-tool-status { color: var(--subako-muted); font-size: 12px; }
91
+ .subako-tool-label { margin-top: 6px; color: var(--subako-muted); font-size: 11px; text-transform: uppercase;
92
+ letter-spacing: 0.04em; }
93
+ /* The arguments and the result wrap instead of scrolling sideways: a chat panel is narrow. */
94
+ .subako-tool pre { margin: 4px 0 0; padding: 4px 6px; border-radius: 6px; background: var(--subako-surface);
95
+ color: var(--subako-fg); font-family: ui-monospace, monospace; font-size: 12px; white-space: pre-wrap;
96
+ overflow-wrap: anywhere; }
97
+ .subako-tool-result[data-error] { color: var(--subako-error); }
98
+ .subako-approval { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 4px 6px;
99
+ border-radius: 6px; background: var(--subako-surface); color: var(--subako-fg); }
100
+ .subako-approval button { padding: 2px 10px; border: 1px solid var(--subako-border); border-radius: 6px;
101
+ background: var(--subako-bg); color: var(--subako-fg); font: inherit; cursor: pointer; }
102
+ .subako-composer { display: flex; gap: 8px; padding: 8px; border-top: 1px solid var(--subako-border);
103
+ background: var(--subako-bg); color: var(--subako-fg); }
104
+ .subako-input { flex: 1 1 auto; resize: none; padding: 6px 8px; border: 1px solid var(--subako-border);
105
+ border-radius: 8px; background: var(--subako-bg); color: var(--subako-fg); font: inherit; }
106
+ .subako-input::placeholder { color: var(--subako-muted); }
107
+ .subako-composer button { padding: 4px 14px; border: 1px solid var(--subako-border); border-radius: 8px;
108
+ background: var(--subako-bg); color: var(--subako-fg); font: inherit; cursor: pointer; }
109
+ .subako-composer button:disabled { opacity: 0.5; cursor: default; }
110
+ }
111
+ /* Element rules from outside — a reset's star and button, an app's own pre — stop at the chat's own
112
+ elements. These guards sit in no layer, with the specificity of two element names, and ask the
113
+ cascade to skip every unlayered declaration and take the layered ones: a reset, which names no
114
+ class, loses to them, and a rule that names a class outranks them. A tool UI the app draws is
115
+ left alone, and so is the root, where the app's own classes go. */
116
+ div:where(.subako-chat) div:where(.subako-viewport, .subako-message, .subako-thinking, .subako-tool,
117
+ .subako-tool-label, .subako-approval, .subako-markdown),
118
+ div:where(.subako-chat) :where(.subako-markdown) :is(h1, h2, h3, h4, h5, h6, p, ul, ol, li, a, code, pre, blockquote,
119
+ table, th, td, hr, img),
120
+ div:where(.subako-chat) details:where(.subako-tool-fold),
121
+ div:where(.subako-chat) summary:where(.subako-tool-summary),
122
+ div:where(.subako-chat) span:where(.subako-tool-name, .subako-tool-status, .subako-writing),
123
+ div:where(.subako-chat) pre:where(.subako-tool-args, .subako-tool-result),
124
+ div:where(.subako-chat) :where(.subako-approval, .subako-composer) > button,
125
+ div:where(.subako-chat) form:where(.subako-composer),
126
+ div:where(.subako-chat) textarea:where(.subako-input),
127
+ div:where(.subako-chat) textarea:where(.subako-input)::placeholder { all: revert-layer; }
128
+ `;
129
+
130
+ /** A `thinking` block: shown, quietly, rather than hidden the way assistant-ui hides it by default. */
131
+ function Reasoning({ text }: ReasoningMessagePartProps): ReactNode {
132
+ return <div className="subako-thinking">{text}</div>;
133
+ }
134
+
135
+ /** GFM on top of CommonMark: tables, strikethrough, task lists and bare URLs, which a model writes unasked. */
136
+ const REMARK_PLUGINS = [remarkGfm];
137
+
138
+ /** react-markdown hands each element its node as well; a link opens beside the app rather than leaving it. */
139
+ const MARKDOWN_COMPONENTS = {
140
+ a: ({ node: _node, ...rest }: ComponentProps<"a"> & { readonly node?: unknown }) => (
141
+ <a {...rest} target="_blank" rel="noreferrer" />
142
+ ),
143
+ };
144
+
145
+ /**
146
+ * The assistant's body as markdown, which is what a model writes. The text
147
+ * arrives whole, so nothing is animated; a fenced block is a plain `pre` and
148
+ * `code`, and `components.Text` is where a highlighter goes. While the run is
149
+ * still writing, a mark follows the text, the way assistant-ui's own text
150
+ * part shows one.
151
+ */
152
+ function Markdown(): ReactNode {
153
+ return (
154
+ <>
155
+ <MarkdownTextPrimitive
156
+ className="subako-markdown"
157
+ remarkPlugins={REMARK_PLUGINS}
158
+ components={MARKDOWN_COMPONENTS}
159
+ smooth={false}
160
+ />
161
+ <MessagePartPrimitive.InProgress>
162
+ <span className="subako-writing">●</span>
163
+ </MessagePartPrimitive.InProgress>
164
+ </>
165
+ );
166
+ }
167
+
168
+ /** A result is text by the time it reaches here; an app's own tool UI may answer with anything. */
169
+ const resultText = (result: unknown): string => (typeof result === "string" ? result : JSON.stringify(result));
170
+
171
+ /** JSON laid out to read, when the text is JSON; anything else is shown as it came. */
172
+ function laidOut(text: string): string {
173
+ try {
174
+ const parsed: unknown = JSON.parse(text);
175
+ return typeof parsed === "object" && parsed !== null ? JSON.stringify(parsed, null, 2) : text;
176
+ } catch {
177
+ return text;
178
+ }
179
+ }
180
+
181
+ /** Whether the server is still waiting on the person: no decision and no cancellation are recorded. */
182
+ const awaiting = (approval: ToolCallMessagePartProps["approval"]): boolean =>
183
+ approval !== undefined && approval.approved === undefined && approval.resolution === undefined;
184
+
185
+ /** What the approval prompt reads off a tool-call part: the call's name, its approval, and the answer. */
186
+ export type SubakoToolApprovalProps = Pick<ToolCallMessagePartProps, "toolName" | "approval" | "respondToApproval">;
187
+
188
+ /**
189
+ * The approval prompt of the chat's own tool row, for a tool UI the app draws
190
+ * itself: Allow and Deny while the server waits on the person, and nothing
191
+ * once there is an answer or the run was cancelled. Spread the tool call's
192
+ * props into it; the answer reaches `resolveApproval` the same way.
193
+ */
194
+ export function SubakoToolApproval({ toolName, approval, respondToApproval }: SubakoToolApprovalProps): ReactNode {
195
+ if (!awaiting(approval)) {
196
+ return null;
197
+ }
198
+ return (
199
+ <div className="subako-approval">
200
+ <span>Run {toolName}?</span>
201
+ <button type="button" onClick={() => void respondToApproval({ approved: true })}>
202
+ Allow
203
+ </button>
204
+ <button type="button" onClick={() => void respondToApproval({ approved: false })}>
205
+ Deny
206
+ </button>
207
+ </div>
208
+ );
209
+ }
210
+
211
+ /** The one word the folded row says about the call. */
212
+ function statusOf(props: ToolCallMessagePartProps): string {
213
+ if (awaiting(props.approval)) {
214
+ return "needs approval";
215
+ }
216
+ if (props.approval?.approved === false) {
217
+ return "denied";
218
+ }
219
+ if (props.result === undefined) {
220
+ return "running";
221
+ }
222
+ return props.isError === true ? "failed" : "done";
223
+ }
224
+
225
+ /**
226
+ * A tool call folded to its name and its status. Opening it shows the
227
+ * arguments and the result, as JSON laid out to read. The approval prompt
228
+ * sits outside the fold, so a closed row still asks.
229
+ */
230
+ function ToolCall(props: ToolCallMessagePartProps): ReactNode {
231
+ const { toolName, args, result, isError } = props;
232
+ return (
233
+ <div className="subako-tool" data-testid={`tool-${toolName}`}>
234
+ <details className="subako-tool-fold">
235
+ <summary className="subako-tool-summary">
236
+ <span className="subako-tool-name">{toolName}</span>
237
+ <span className="subako-tool-status">{statusOf(props)}</span>
238
+ </summary>
239
+ <div className="subako-tool-label">Input</div>
240
+ <pre className="subako-tool-args">{JSON.stringify(args, null, 2)}</pre>
241
+ {result === undefined ? null : (
242
+ <>
243
+ <div className="subako-tool-label">Output</div>
244
+ <pre className="subako-tool-result" data-error={isError === true ? "" : undefined}>
245
+ {laidOut(resultText(result))}
246
+ </pre>
247
+ </>
248
+ )}
249
+ </details>
250
+ <SubakoToolApproval {...props} />
251
+ </div>
252
+ );
253
+ }
254
+
255
+ /** The components an assistant message's parts are drawn with, as `MessagePrimitive.Parts` takes them. */
256
+ type PartComponents = NonNullable<ComponentProps<typeof MessagePrimitive.Parts>["components"]>;
257
+
258
+ /** The app's part components, handed down from `<SubakoChat>` to every assistant message. */
259
+ const PartsContext = createContext<PartComponents | undefined>(undefined);
260
+
261
+ /**
262
+ * The chat's own parts under the app's: its markdown body, its reasoning block
263
+ * and its tool row stay wherever the app names nothing, so a `by_name` keeps
264
+ * the row for every other tool, and an `Override` takes all of them. A
265
+ * `ChainOfThought` draws the reasoning and the tool calls itself.
266
+ */
267
+ function partsOf(theirs: PartComponents | undefined): PartComponents {
268
+ if (theirs?.ChainOfThought !== undefined) {
269
+ return { Text: Markdown, ...theirs };
270
+ }
271
+ const tools = theirs?.tools;
272
+ return {
273
+ Text: Markdown,
274
+ Reasoning,
275
+ ...theirs,
276
+ tools: tools !== undefined && "Override" in tools ? tools : { Fallback: ToolCall, ...tools },
277
+ };
278
+ }
279
+
280
+ function UserMessage(): ReactNode {
281
+ return (
282
+ <MessagePrimitive.Root className="subako-message subako-user">
283
+ <MessagePrimitive.Parts />
284
+ </MessagePrimitive.Root>
285
+ );
286
+ }
287
+
288
+ function AssistantMessage(): ReactNode {
289
+ const theirs = useContext(PartsContext);
290
+ return (
291
+ <MessagePrimitive.Root className="subako-message subako-assistant">
292
+ {/* A tool the app registered a UI for keeps it; everything else falls back to the row above. */}
293
+ <MessagePrimitive.Parts components={partsOf(theirs)} />
294
+ </MessagePrimitive.Root>
295
+ );
296
+ }
297
+
298
+ export interface SubakoChatProps {
299
+ /** The connection from `useSession`, which is `null` until its effect has opened one. */
300
+ readonly session: SubakoSession | null;
301
+ /** Added to the root element, for an app that styles the thread itself. */
302
+ readonly className?: string;
303
+ /**
304
+ * The components an assistant message's parts are drawn with, as
305
+ * `MessagePrimitive.Parts` takes them: `Text` for the body, in place of the
306
+ * chat's own markdown; `tools` for the tool calls, whose `Fallback` replaces
307
+ * the chat's own row and whose `by_name` replaces it for the tools named.
308
+ * The chat's body, reasoning block and tool row stay wherever nothing is
309
+ * named.
310
+ */
311
+ readonly components?: PartComponents;
312
+ /**
313
+ * The page's CSP nonce, for a page whose `style-src-elem` names one. Without
314
+ * it the browser drops the chat's own stylesheet and the thread renders
315
+ * unstyled; a page with no such policy needs nothing here.
316
+ */
317
+ readonly nonce?: string;
318
+ /** Rendered inside the runtime, which is where a tool UI registers itself. */
319
+ readonly children?: ReactNode;
320
+ }
321
+
322
+ /**
323
+ * One session as a working chat: the transcript, the tool calls with their
324
+ * approvals, a composer, and a stop button while a run is under way. The
325
+ * session is the app's own, so the chat opens no second connection; until it
326
+ * has one the thread is empty and the composer is disabled. It exists for the
327
+ * first ten minutes; an app that wants its own thread keeps `useSubakoRuntime`
328
+ * and builds the rest from assistant-ui's primitives.
329
+ */
330
+ export function SubakoChat({ session, className, components, nonce, children }: SubakoChatProps): ReactNode {
331
+ const runtime = useSubakoRuntime(session);
332
+ const state = useSessionState(session);
333
+ return (
334
+ <AssistantRuntimeProvider runtime={runtime}>
335
+ <style nonce={nonce}>{STYLE}</style>
336
+ {children}
337
+ <PartsContext.Provider value={components}>
338
+ <ThreadPrimitive.Root className={className === undefined ? "subako-chat" : `subako-chat ${className}`}>
339
+ <ThreadPrimitive.Viewport className="subako-viewport">
340
+ <ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
341
+ </ThreadPrimitive.Viewport>
342
+ <ComposerPrimitive.Root className="subako-composer">
343
+ <ComposerPrimitive.Input className="subako-input" rows={1} placeholder="Message the agent" />
344
+ <ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
345
+ {state?.isRunning === true ? <ComposerPrimitive.Cancel>Stop</ComposerPrimitive.Cancel> : null}
346
+ </ComposerPrimitive.Root>
347
+ </ThreadPrimitive.Root>
348
+ </PartsContext.Provider>
349
+ </AssistantRuntimeProvider>
350
+ );
351
+ }
package/src/convert.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { ThreadMessageLike } from "@assistant-ui/react";
2
+ import type { ContentBlockBody, TranscriptMessage } from "@subako-ai/sdk";
3
+
4
+ /** One part of an assistant-ui message; its content is either a string or these. */
5
+ type ContentPart = Extract<ThreadMessageLike["content"], readonly unknown[]>[number];
6
+
7
+ /** The part a `tool_call` block becomes, with the result and the approval joined to it. */
8
+ type ToolCallPart = Extract<ContentPart, { type: "tool-call" }>;
9
+
10
+ /** One `tool_call` block of the log, as the SDK's `transcript` joined it. */
11
+ type TranscriptCall = NonNullable<TranscriptMessage["toolCalls"]>[number];
12
+
13
+ /** The arguments arrive as JSON off the wire; assistant-ui asks for them typed as JSON. */
14
+ const asArguments = (args: Record<string, unknown>): NonNullable<ToolCallPart["args"]> =>
15
+ args as NonNullable<ToolCallPart["args"]>;
16
+
17
+ /** A tool result is content blocks; assistant-ui renders one value, so the blocks are read as text. */
18
+ const resultText = (content: readonly ContentBlockBody[]): string =>
19
+ content.map((block) => (block.type === "text" ? block.data.text : JSON.stringify(block.data))).join("\n");
20
+
21
+ /**
22
+ * The approval, in assistant-ui's spelling: an id with no decision is what its
23
+ * renderers read as "still waiting", which is what shows the prompt.
24
+ */
25
+ function approvalOf(call: TranscriptCall): ToolCallPart["approval"] {
26
+ switch (call.approval) {
27
+ case "pending":
28
+ return { id: call.id };
29
+ case "allowed":
30
+ return { id: call.id, approved: true };
31
+ case "denied":
32
+ return { id: call.id, approved: false };
33
+ default:
34
+ return undefined;
35
+ }
36
+ }
37
+
38
+ function toolCallPart(call: TranscriptCall): ContentPart {
39
+ const approval = approvalOf(call);
40
+ return {
41
+ type: "tool-call",
42
+ toolCallId: call.id,
43
+ toolName: call.name,
44
+ args: asArguments(call.arguments),
45
+ ...(call.result === undefined ? {} : { result: resultText(call.result.content), isError: call.result.is_error }),
46
+ ...(approval === undefined ? {} : { approval }),
47
+ };
48
+ }
49
+
50
+ /**
51
+ * One message of the log as assistant-ui reads it: `text` becomes a text part,
52
+ * `thinking` a reasoning part, and every `tool_call` the joined call, so its
53
+ * result and its approval travel with it.
54
+ *
55
+ * The id is the event's `seq`, which the log never reuses. The conversion is
56
+ * pure, and assistant-ui caches it on the identity of the message object rather
57
+ * than on that id — so what keeps the cache warm is the connection's transcript
58
+ * handing back the same message until an event touches it.
59
+ */
60
+ export function convertMessage(message: TranscriptMessage): ThreadMessageLike {
61
+ // `transcript` joins result and approval onto the calls, so the blocks are read through them.
62
+ const calls = new Map((message.toolCalls ?? []).map((call) => [call.id, call]));
63
+ const content: ContentPart[] = [];
64
+ for (const block of message.message.content) {
65
+ switch (block.type) {
66
+ case "text":
67
+ content.push({ type: "text", text: block.data.text });
68
+ break;
69
+ case "thinking":
70
+ content.push({ type: "reasoning", text: block.data.thinking });
71
+ break;
72
+ case "tool_call":
73
+ content.push(toolCallPart(calls.get(block.data.id) ?? { ...block.data }));
74
+ break;
75
+ default:
76
+ break;
77
+ }
78
+ }
79
+ return {
80
+ role: message.role,
81
+ id: String(message.seq),
82
+ content,
83
+ // Where a user turn came from: the client, a steer, or something outside the browser.
84
+ ...(message.source === undefined ? {} : { metadata: { custom: { source: message.source } } }),
85
+ };
86
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { SubakoChat, type SubakoChatProps, SubakoToolApproval, type SubakoToolApprovalProps } from "./chat.js";
2
+ export { convertMessage } from "./convert.js";
3
+ export { type SubakoSession, useSubakoRuntime } from "./runtime.js";
package/src/runtime.ts ADDED
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import type { AppendMessage, AssistantRuntime } from "@assistant-ui/react";
4
+ import { useExternalStoreRuntime } from "@assistant-ui/react";
5
+ import { useSessionState } from "@subako-ai/react";
6
+ import type { SessionState, TranscriptMessage } from "@subako-ai/sdk";
7
+ import { convertMessage } from "./convert.js";
8
+
9
+ /**
10
+ * What the runtime reads off a session: the SDK's `SessionConnection`, or
11
+ * anything else that carries the same members. It is spelled structurally so a
12
+ * session from `useSession` fits without this package naming the hook's type.
13
+ */
14
+ export interface SubakoSession {
15
+ /** The connection as it stands, transcript and all: the store the runtime subscribes to and reads. */
16
+ readonly state: SessionState;
17
+ /** Calls `listener` with the state after every change; returns an unsubscribe. */
18
+ readonly subscribe: (listener: (state: SessionState) => void) => () => void;
19
+ /** Posts a user turn, which queues a run or steers the active one. */
20
+ send(text: string): Promise<void>;
21
+ /** Cancels the active run. */
22
+ cancel(): Promise<void>;
23
+ resolveApproval(callId: string, decision: "allow" | "deny", message?: string): Promise<void>;
24
+ }
25
+
26
+ /** The thread before the connection is open: no messages, and the same array on every render. */
27
+ const NOTHING: readonly TranscriptMessage[] = [];
28
+
29
+ /** assistant-ui hands the composer's parts; a session takes one string. */
30
+ function appendedText(message: AppendMessage): string {
31
+ let text = "";
32
+ for (const part of message.content) {
33
+ if (part.type === "text") {
34
+ text += part.text;
35
+ }
36
+ }
37
+ return text;
38
+ }
39
+
40
+ /**
41
+ * An assistant-ui runtime over one Subako session: the log is the store, and
42
+ * every action the thread offers is a call on the session.
43
+ *
44
+ * | assistant-ui | Subako |
45
+ * | ------------------------- | --------------------------------------------- |
46
+ * | `messages` | `state.transcript`, through `convertMessage` |
47
+ * | `isRunning` | `state.isRunning` |
48
+ * | `onNew` | `send(text)` |
49
+ * | `onCancel` | `cancel()` |
50
+ * | `onRespondToToolApproval` | `resolveApproval(callId, decision, reason)` |
51
+ *
52
+ * The runtime keeps no messages of its own: what the server put on the log is
53
+ * what the thread shows, and a send that fails leaves the log as it was. A
54
+ * `null` session is the one `useSession` has yet to open: an empty thread, and
55
+ * a composer disabled until there is something to send to.
56
+ *
57
+ * The session is a value, not something React re-renders for, so the runtime
58
+ * subscribes to it here: a chat handed the session by a parent that does not
59
+ * re-render still follows the log.
60
+ */
61
+ export function useSubakoRuntime(session: SubakoSession | null): AssistantRuntime {
62
+ // The snapshot this render was woken for, which is what the thread is built from.
63
+ const state = useSessionState(session);
64
+ return useExternalStoreRuntime<TranscriptMessage>({
65
+ messages: state?.transcript ?? NOTHING,
66
+ isRunning: state?.isRunning ?? false,
67
+ isDisabled: session === null,
68
+ convertMessage,
69
+ onNew: async (message) => {
70
+ await session?.send(appendedText(message));
71
+ },
72
+ onCancel: async () => {
73
+ await session?.cancel();
74
+ },
75
+ // The approval's id is the call it gates, which is what the session answers by.
76
+ onRespondToToolApproval: async ({ approvalId, approved, reason }) => {
77
+ await session?.resolveApproval(approvalId, approved ? "allow" : "deny", reason);
78
+ },
79
+ });
80
+ }