@tangle-network/sandbox-ui 0.39.1 → 0.40.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.
@@ -0,0 +1,2306 @@
1
+ import {
2
+ providerLabel
3
+ } from "./chunk-7JLB7UZ3.js";
4
+
5
+ // src/assistant/sse.ts
6
+ var SSEChunkParser = class {
7
+ buffer = "";
8
+ current = {};
9
+ push(chunk) {
10
+ this.buffer += chunk;
11
+ const lines = this.buffer.split("\n");
12
+ this.buffer = lines.pop() ?? "";
13
+ return this.processLines(lines);
14
+ }
15
+ flush() {
16
+ const lines = this.buffer ? [this.buffer] : [];
17
+ this.buffer = "";
18
+ const events = this.processLines(lines);
19
+ const finalEvent = this.parseCurrent();
20
+ if (finalEvent) {
21
+ events.push(finalEvent);
22
+ this.current = {};
23
+ }
24
+ return events;
25
+ }
26
+ processLines(lines) {
27
+ const events = [];
28
+ for (const rawLine of lines) {
29
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
30
+ if (line.startsWith(":")) continue;
31
+ if (line === "") {
32
+ const parsed = this.parseCurrent();
33
+ if (parsed) events.push(parsed);
34
+ this.current = {};
35
+ continue;
36
+ }
37
+ if (line.startsWith("id:")) {
38
+ this.current.id = line.slice(3).trim();
39
+ } else if (line.startsWith("event:")) {
40
+ this.current.event = line.slice(6).trim();
41
+ } else if (line.startsWith("data:")) {
42
+ let value = line.slice(5);
43
+ if (value.startsWith(" ")) value = value.slice(1);
44
+ this.current.data = this.current.data !== void 0 ? `${this.current.data}
45
+ ${value}` : value;
46
+ }
47
+ }
48
+ return events;
49
+ }
50
+ parseCurrent() {
51
+ if (this.current.data === void 0) return null;
52
+ const rawData = this.current.data.trim();
53
+ if (!rawData) return null;
54
+ let data;
55
+ try {
56
+ data = JSON.parse(rawData);
57
+ } catch {
58
+ data = rawData;
59
+ }
60
+ return {
61
+ data,
62
+ rawData,
63
+ eventId: this.current.id,
64
+ eventType: this.current.event
65
+ };
66
+ }
67
+ };
68
+ async function readSSEEvents(body, onEvent) {
69
+ const reader = body.getReader();
70
+ const decoder = new TextDecoder();
71
+ const parser = new SSEChunkParser();
72
+ try {
73
+ while (true) {
74
+ const { done, value } = await reader.read();
75
+ if (done) break;
76
+ for (const event of parser.push(decoder.decode(value, { stream: true }))) {
77
+ onEvent(event);
78
+ }
79
+ }
80
+ const tail = decoder.decode();
81
+ if (tail) {
82
+ for (const event of parser.push(tail)) onEvent(event);
83
+ }
84
+ for (const event of parser.flush()) onEvent(event);
85
+ } catch (err) {
86
+ await reader.cancel(err).catch(() => {
87
+ });
88
+ throw err;
89
+ } finally {
90
+ reader.releaseLock();
91
+ }
92
+ }
93
+
94
+ // src/assistant/client.ts
95
+ var EMPTY_MODELS = { default: null, models: [] };
96
+ function asObject(v) {
97
+ return v && typeof v === "object" && !Array.isArray(v) ? v : null;
98
+ }
99
+ function reqStr(v) {
100
+ return typeof v === "string" && v !== "" ? v : null;
101
+ }
102
+ function numOrNull(v) {
103
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
104
+ }
105
+ function parseRequirement(raw) {
106
+ if (!raw || typeof raw !== "object") return null;
107
+ const r = raw;
108
+ if (typeof r.provider !== "string" || r.provider === "") return null;
109
+ if (typeof r.connected !== "boolean") return null;
110
+ const kind = r.kind === "integration" || r.kind === "github_app" ? r.kind : void 0;
111
+ const connectUrl = typeof r.connectUrl === "string" || r.connectUrl === null ? r.connectUrl : void 0;
112
+ return {
113
+ provider: r.provider,
114
+ connected: r.connected,
115
+ ...kind ? { kind } : {},
116
+ ...connectUrl !== void 0 ? { connectUrl } : {}
117
+ };
118
+ }
119
+ function parseRequirements(v) {
120
+ if (!Array.isArray(v)) return void 0;
121
+ const out = [];
122
+ for (const item of v) {
123
+ const parsed = parseRequirement(item);
124
+ if (parsed) out.push(parsed);
125
+ }
126
+ return out;
127
+ }
128
+ function toStreamEvent(event, data) {
129
+ const obj = asObject(data);
130
+ if (!obj) return null;
131
+ switch (event) {
132
+ case "thread": {
133
+ const threadId = reqStr(obj.threadId);
134
+ const turnId = reqStr(obj.turnId);
135
+ if (!threadId || !turnId) return null;
136
+ return {
137
+ type: "thread",
138
+ data: { threadId, turnId, model: reqStr(obj.model) }
139
+ };
140
+ }
141
+ case "delta": {
142
+ if (typeof obj.text !== "string") return null;
143
+ return { type: "delta", data: { text: obj.text } };
144
+ }
145
+ case "reasoning": {
146
+ if (typeof obj.text !== "string") return null;
147
+ return { type: "reasoning", data: { text: obj.text } };
148
+ }
149
+ case "tool_call": {
150
+ const callId = reqStr(obj.callId);
151
+ const name = reqStr(obj.name);
152
+ if (!callId || !name) return null;
153
+ return { type: "tool_call", data: { callId, name } };
154
+ }
155
+ case "tool_result": {
156
+ const callId = reqStr(obj.callId);
157
+ const name = reqStr(obj.name);
158
+ if (!callId || !name) return null;
159
+ return {
160
+ type: "tool_result",
161
+ data: {
162
+ callId,
163
+ name,
164
+ ok: Boolean(obj.ok),
165
+ output: obj.output,
166
+ error: obj.error
167
+ }
168
+ };
169
+ }
170
+ case "tool_proposal": {
171
+ const callId = reqStr(obj.callId);
172
+ const name = reqStr(obj.name);
173
+ if (!callId || !name) return null;
174
+ return {
175
+ type: "tool_proposal",
176
+ data: {
177
+ proposalId: obj.proposalId == null ? null : reqStr(obj.proposalId),
178
+ callId,
179
+ name,
180
+ args: obj.args,
181
+ requirements: parseRequirements(obj.requirements)
182
+ }
183
+ };
184
+ }
185
+ case "usage":
186
+ return {
187
+ type: "usage",
188
+ data: {
189
+ promptTokens: numOrNull(obj.promptTokens),
190
+ completionTokens: numOrNull(obj.completionTokens),
191
+ costUsd: numOrNull(obj.costUsd),
192
+ balanceUsd: numOrNull(obj.balanceUsd),
193
+ replayed: Boolean(obj.replayed)
194
+ }
195
+ };
196
+ case "done": {
197
+ const turnId = reqStr(obj.turnId);
198
+ const status = reqStr(obj.status);
199
+ if (!turnId || !status) return null;
200
+ return {
201
+ type: "done",
202
+ data: {
203
+ turnId,
204
+ status,
205
+ proposed: Boolean(obj.proposed),
206
+ capped: Boolean(obj.capped)
207
+ }
208
+ };
209
+ }
210
+ case "error":
211
+ return {
212
+ type: "error",
213
+ data: {
214
+ code: reqStr(obj.code) ?? "STREAM_FAILED",
215
+ message: reqStr(obj.message) ?? "The assistant stream failed"
216
+ }
217
+ };
218
+ default:
219
+ return null;
220
+ }
221
+ }
222
+ async function readErrorEvent(res) {
223
+ try {
224
+ const body = await res.json();
225
+ return {
226
+ type: "error",
227
+ data: {
228
+ code: body.error?.code ?? `HTTP_${res.status}`,
229
+ message: body.error?.message ?? `Request failed (${res.status})`
230
+ }
231
+ };
232
+ } catch {
233
+ return {
234
+ type: "error",
235
+ data: {
236
+ code: `HTTP_${res.status}`,
237
+ message: `Request failed (${res.status})`
238
+ }
239
+ };
240
+ }
241
+ }
242
+ function parseRestoredProposal(raw) {
243
+ if (!raw || typeof raw !== "object") return null;
244
+ const r = raw;
245
+ if (typeof r.proposalId !== "string" || r.proposalId === "") return null;
246
+ if (typeof r.callId !== "string" || r.callId === "") return null;
247
+ if (typeof r.name !== "string" || r.name === "") return null;
248
+ const requirements = Array.isArray(r.requirements) ? r.requirements.map(parseRequirement).filter((x) => x !== null) : void 0;
249
+ return {
250
+ proposalId: r.proposalId,
251
+ callId: r.callId,
252
+ name: r.name,
253
+ args: r.args,
254
+ ...requirements ? { requirements } : {}
255
+ };
256
+ }
257
+ function createAssistantClient(config) {
258
+ const base = config.baseUrl.replace(/\/+$/, "");
259
+ const credentials = config.credentials ?? "include";
260
+ const authHeaders = () => config.headers?.() ?? {};
261
+ const url = (path) => `${base}${path}`;
262
+ async function postJson(path, body) {
263
+ try {
264
+ const res = await fetch(url(path), {
265
+ method: "POST",
266
+ headers: { ...authHeaders(), "Content-Type": "application/json" },
267
+ credentials,
268
+ body: JSON.stringify(body)
269
+ });
270
+ const json = await res.json();
271
+ if (!res.ok) {
272
+ return {
273
+ success: false,
274
+ error: json?.error?.message || `HTTP ${res.status}`
275
+ };
276
+ }
277
+ return { success: true, data: json?.data ?? json };
278
+ } catch (err) {
279
+ return {
280
+ success: false,
281
+ error: err instanceof Error ? err.message : "Request failed"
282
+ };
283
+ }
284
+ }
285
+ return {
286
+ async fetchModels(signal) {
287
+ try {
288
+ const res = await fetch(url("/models"), {
289
+ method: "GET",
290
+ headers: authHeaders(),
291
+ credentials,
292
+ signal
293
+ });
294
+ if (!res.ok) return { ok: false, data: EMPTY_MODELS };
295
+ const body = await res.json();
296
+ if (!Array.isArray(body.models))
297
+ return { ok: false, data: EMPTY_MODELS };
298
+ const models = [];
299
+ for (const m of body.models) {
300
+ const slug = typeof m.slug === "string" ? m.slug : null;
301
+ if (!slug) continue;
302
+ const label = typeof m.label === "string" ? m.label : slug;
303
+ const option = { slug, label };
304
+ if (typeof m.promptUsdPerMillion === "number") {
305
+ option.promptUsdPerMillion = m.promptUsdPerMillion;
306
+ }
307
+ if (typeof m.contextTokens === "number") {
308
+ option.contextTokens = m.contextTokens;
309
+ }
310
+ models.push(option);
311
+ }
312
+ return {
313
+ // Empty ⇒ catalog unavailable: report not-ok so the caller retries next
314
+ // mount instead of caching an empty picker for the session.
315
+ ok: models.length > 0,
316
+ data: {
317
+ default: typeof body.default === "string" ? body.default : null,
318
+ models
319
+ }
320
+ };
321
+ } catch {
322
+ return { ok: false, data: EMPTY_MODELS };
323
+ }
324
+ },
325
+ async fetchThreads(signal) {
326
+ try {
327
+ const res = await fetch(url("/threads"), {
328
+ method: "GET",
329
+ headers: authHeaders(),
330
+ credentials,
331
+ signal
332
+ });
333
+ if (!res.ok) return null;
334
+ const body = await res.json();
335
+ if (!Array.isArray(body.threads)) return null;
336
+ const out = [];
337
+ for (const t of body.threads) {
338
+ const id = typeof t.id === "string" ? t.id : null;
339
+ if (!id) continue;
340
+ out.push({
341
+ id,
342
+ title: typeof t.title === "string" ? t.title : null,
343
+ createdAt: typeof t.createdAt === "string" ? t.createdAt : "",
344
+ updatedAt: typeof t.updatedAt === "string" ? t.updatedAt : ""
345
+ });
346
+ }
347
+ return out;
348
+ } catch {
349
+ return null;
350
+ }
351
+ },
352
+ async fetchThreadHistory(threadId, signal) {
353
+ try {
354
+ const res = await fetch(
355
+ url(`/threads/${encodeURIComponent(threadId)}/messages`),
356
+ {
357
+ method: "GET",
358
+ headers: authHeaders(),
359
+ credentials,
360
+ signal
361
+ }
362
+ );
363
+ if (res.status === 404) return { status: "gone" };
364
+ if (!res.ok) return { status: "error" };
365
+ const body = await res.json();
366
+ if (!Array.isArray(body.messages)) return { status: "error" };
367
+ const out = [];
368
+ for (const m of body.messages) {
369
+ const id = typeof m.id === "string" ? m.id : null;
370
+ const role = m.role === "user" || m.role === "assistant" ? m.role : null;
371
+ const text = typeof m.text === "string" ? m.text : null;
372
+ if (id && role && text != null) out.push({ id, role, text });
373
+ }
374
+ const proposals = [];
375
+ if (Array.isArray(body.proposals)) {
376
+ for (const p of body.proposals) {
377
+ const parsed = parseRestoredProposal(p);
378
+ if (parsed) proposals.push(parsed);
379
+ }
380
+ }
381
+ return { status: "ok", messages: out, proposals };
382
+ } catch {
383
+ if (signal?.aborted) return { status: "error" };
384
+ return { status: "error" };
385
+ }
386
+ },
387
+ async streamChat(req, onEvent, signal) {
388
+ const res = await fetch(url("/chat"), {
389
+ method: "POST",
390
+ headers: {
391
+ ...authHeaders(),
392
+ "Content-Type": "application/json",
393
+ Accept: "text/event-stream"
394
+ },
395
+ credentials,
396
+ body: JSON.stringify(req),
397
+ signal
398
+ });
399
+ if (!res.ok) {
400
+ onEvent(await readErrorEvent(res));
401
+ return;
402
+ }
403
+ if (!res.body) {
404
+ onEvent({
405
+ type: "error",
406
+ data: {
407
+ code: "NO_BODY",
408
+ message: "The assistant stream is unavailable"
409
+ }
410
+ });
411
+ return;
412
+ }
413
+ let settled = false;
414
+ await readSSEEvents(res.body, (frame) => {
415
+ const ev = toStreamEvent(frame.eventType ?? null, frame.data);
416
+ if (!ev) return;
417
+ if (ev.type === "done" || ev.type === "error") settled = true;
418
+ onEvent(ev);
419
+ });
420
+ if (!settled) {
421
+ onEvent({
422
+ type: "error",
423
+ data: {
424
+ code: "STREAM_CLOSED",
425
+ message: "The assistant stream ended unexpectedly"
426
+ }
427
+ });
428
+ }
429
+ },
430
+ async confirmProposal(proposalId) {
431
+ const res = await postJson("/tools/execute", { proposalId });
432
+ if (!res.success) {
433
+ return {
434
+ ok: false,
435
+ error: res.error ?? "The action could not be completed"
436
+ };
437
+ }
438
+ const body = res.data;
439
+ if (body?.success) {
440
+ return { ok: true, output: body.output, retryable: body.retryable };
441
+ }
442
+ return {
443
+ ok: false,
444
+ error: body?.error?.message ?? "The action could not be completed"
445
+ };
446
+ }
447
+ };
448
+ }
449
+
450
+ // src/assistant/client-context.tsx
451
+ import { createContext, useContext } from "react";
452
+ import { jsx } from "react/jsx-runtime";
453
+ var AssistantClientContext = createContext(null);
454
+ function AssistantClientProvider({
455
+ client,
456
+ children
457
+ }) {
458
+ return /* @__PURE__ */ jsx(AssistantClientContext.Provider, { value: client, children });
459
+ }
460
+ function useAssistantClient() {
461
+ const client = useContext(AssistantClientContext);
462
+ if (!client) {
463
+ throw new Error(
464
+ "useAssistantClient must be used within an <AssistantClientProvider>"
465
+ );
466
+ }
467
+ return client;
468
+ }
469
+
470
+ // src/assistant/useAssistantChat.ts
471
+ import { useCallback, useEffect, useReducer, useRef, useState } from "react";
472
+
473
+ // src/assistant/persistence.ts
474
+ var VERSION = "v1";
475
+ function keyFor(userId) {
476
+ return userId ? `assistant:${VERSION}:${userId}` : null;
477
+ }
478
+ function loadThread(userId) {
479
+ const key = keyFor(userId);
480
+ if (!key) return { threadId: null, model: null };
481
+ try {
482
+ const raw = localStorage.getItem(key);
483
+ if (!raw) return { threadId: null, model: null };
484
+ const parsed = JSON.parse(raw);
485
+ return {
486
+ threadId: typeof parsed.threadId === "string" ? parsed.threadId : null,
487
+ model: typeof parsed.model === "string" ? parsed.model : null
488
+ };
489
+ } catch {
490
+ return { threadId: null, model: null };
491
+ }
492
+ }
493
+ function saveThread(userId, thread) {
494
+ const key = keyFor(userId);
495
+ if (!key) return;
496
+ try {
497
+ localStorage.setItem(
498
+ key,
499
+ JSON.stringify({ threadId: thread.threadId, model: thread.model })
500
+ );
501
+ } catch {
502
+ }
503
+ }
504
+
505
+ // src/assistant/presentation.ts
506
+ var LOW_BALANCE_THRESHOLD = 1;
507
+ var ADD_CREDITS_CTA = { label: "Add credits", to: "/app/billing" };
508
+ var CONNECT_CTA = {
509
+ label: "Connect an integration",
510
+ to: "/app/integrations"
511
+ };
512
+ function presentError(code, message) {
513
+ switch (code) {
514
+ case "INSUFFICIENT_BALANCE":
515
+ return {
516
+ message: "You're out of credits. Add credits to keep using the assistant.",
517
+ cta: ADD_CREDITS_CTA
518
+ };
519
+ case "MODEL_ACCESS_UNCONFIGURED":
520
+ return {
521
+ message: "Model access isn't configured for your account yet. Please contact support.",
522
+ cta: null
523
+ };
524
+ case "BILLING_UNAVAILABLE":
525
+ return {
526
+ message: "Billing is temporarily unavailable. Try again in a moment.",
527
+ cta: null
528
+ };
529
+ case "TOO_MANY_STREAMS":
530
+ return {
531
+ message: "You have too many assistant requests in flight. Wait a moment and retry.",
532
+ cta: null
533
+ };
534
+ case "THREAD_BUSY":
535
+ case "TURN_IN_PROGRESS":
536
+ return {
537
+ message: "A previous request is still finishing. Try again shortly.",
538
+ cta: null
539
+ };
540
+ case "INTEGRATION_DISCONNECTED":
541
+ return {
542
+ message: `${message} Connect the integration, then ask again.`,
543
+ cta: CONNECT_CTA
544
+ };
545
+ case "TOOL_FAILED":
546
+ case "NETWORK":
547
+ return { message: message || "Something went wrong.", cta: null };
548
+ default:
549
+ return { message: message || "Something went wrong.", cta: null };
550
+ }
551
+ }
552
+ function asRecord(args) {
553
+ return args && typeof args === "object" && !Array.isArray(args) ? args : {};
554
+ }
555
+ function str(v) {
556
+ if (v == null) return "";
557
+ return typeof v === "string" ? v : JSON.stringify(v);
558
+ }
559
+ function nonEmptyStr(v) {
560
+ return typeof v === "string" && v.trim() !== "" ? v : null;
561
+ }
562
+ function parseProposalSkills(v) {
563
+ if (!Array.isArray(v) || v.length === 0) return void 0;
564
+ const out = [];
565
+ for (const item of v) {
566
+ const rec = asRecord(item);
567
+ const name = nonEmptyStr(rec.name);
568
+ if (!name) continue;
569
+ out.push({ name, description: nonEmptyStr(rec.description) });
570
+ }
571
+ return out.length > 0 ? out : void 0;
572
+ }
573
+ function humanizeToolName(name) {
574
+ const spaced = name.replace(/_/g, " ").trim();
575
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
576
+ }
577
+ var TOOL_ACTIVITY_LABELS = {
578
+ get_workflow_schema: "Reading the workflow format",
579
+ list_workflows: "Listing workflows",
580
+ get_workflow: "Reading workflow",
581
+ validate_workflow: "Validating workflow",
582
+ list_skills: "Listing skills",
583
+ get_skill: "Reading skill",
584
+ list_integrations: "Checking integrations",
585
+ get_credit_balance: "Checking balance",
586
+ get_usage: "Checking usage",
587
+ list_api_keys: "Listing API keys"
588
+ };
589
+ function describeToolActivity(name) {
590
+ return TOOL_ACTIVITY_LABELS[name] ?? humanizeToolName(name);
591
+ }
592
+ function describeProposal(proposal) {
593
+ const args = asRecord(proposal.args);
594
+ const workflowYaml = nonEmptyStr(args.yaml);
595
+ const workflowPreview = workflowYaml ? {
596
+ label: "Workflow definition",
597
+ content: workflowYaml,
598
+ kind: "workflow"
599
+ } : null;
600
+ switch (proposal.name) {
601
+ case "create_workflow":
602
+ return { title: "Create workflow", preview: workflowPreview, fields: [] };
603
+ // author_workflow creates a workflow PLUS the new skills it needs in one
604
+ // unit; show the YAML and name each new skill rather than dumping the raw
605
+ // skills JSON (the card is the canonical, readable view of the proposal).
606
+ case "author_workflow":
607
+ return {
608
+ title: "Create workflow",
609
+ preview: workflowPreview,
610
+ fields: [],
611
+ skills: parseProposalSkills(args.skills)
612
+ };
613
+ case "update_workflow":
614
+ return {
615
+ title: "Update workflow",
616
+ preview: workflowPreview,
617
+ fields: [{ label: "Workflow id", value: str(args.id) }]
618
+ };
619
+ case "set_workflow_enabled":
620
+ return {
621
+ title: args.enabled ? "Enable workflow" : "Disable workflow",
622
+ preview: null,
623
+ fields: [{ label: "Workflow id", value: str(args.id) }]
624
+ };
625
+ case "create_skill": {
626
+ const prompt = nonEmptyStr(args.systemPrompt);
627
+ const fields = [
628
+ { label: "Name", value: str(args.name) }
629
+ ];
630
+ if (nonEmptyStr(args.description))
631
+ fields.push({ label: "Description", value: str(args.description) });
632
+ return {
633
+ title: "Create skill",
634
+ preview: prompt ? { label: "Instructions", content: prompt, kind: "text" } : null,
635
+ fields
636
+ };
637
+ }
638
+ case "update_skill": {
639
+ const prompt = nonEmptyStr(args.systemPrompt);
640
+ const fields = [
641
+ { label: "Skill id", value: str(args.id) }
642
+ ];
643
+ if (nonEmptyStr(args.name))
644
+ fields.push({ label: "Name", value: str(args.name) });
645
+ if (nonEmptyStr(args.description))
646
+ fields.push({ label: "Description", value: str(args.description) });
647
+ return {
648
+ title: "Update skill",
649
+ preview: prompt ? { label: "Instructions", content: prompt, kind: "text" } : null,
650
+ fields
651
+ };
652
+ }
653
+ case "delete_skill":
654
+ return {
655
+ title: "Delete skill",
656
+ preview: null,
657
+ fields: [{ label: "Skill id", value: str(args.id) }]
658
+ };
659
+ case "create_api_key": {
660
+ const fields = [
661
+ { label: "Name", value: str(args.name) }
662
+ ];
663
+ if (args.product != null)
664
+ fields.push({ label: "Product", value: str(args.product) });
665
+ if (args.budgetUsd != null)
666
+ fields.push({ label: "Budget (USD)", value: str(args.budgetUsd) });
667
+ return { title: "Create API key", preview: null, fields };
668
+ }
669
+ case "revoke_api_key":
670
+ return {
671
+ title: "Revoke API key",
672
+ preview: null,
673
+ fields: [{ label: "Key id", value: str(args.keyId) }]
674
+ };
675
+ case "invoke_integration": {
676
+ const fields = [
677
+ { label: "Action", value: str(args.path) }
678
+ ];
679
+ if (args.input != null)
680
+ fields.push({ label: "Input", value: str(args.input) });
681
+ return { title: "Run integration action", preview: null, fields };
682
+ }
683
+ default: {
684
+ const fields = Object.entries(args).map(([label, value]) => ({
685
+ label,
686
+ value: str(value)
687
+ }));
688
+ return { title: humanizeToolName(proposal.name), preview: null, fields };
689
+ }
690
+ }
691
+ }
692
+ function describeOutcome(name, output) {
693
+ const o = asRecord(output);
694
+ switch (name) {
695
+ case "author_workflow":
696
+ case "create_workflow": {
697
+ const wf = asRecord(o.workflow);
698
+ const skillCount = Array.isArray(o.skills) ? o.skills.length : 0;
699
+ if (wf.name) {
700
+ return skillCount > 0 ? `Created workflow "${str(wf.name)}" and ${skillCount} skill${skillCount === 1 ? "" : "s"}.` : `Created workflow "${str(wf.name)}".`;
701
+ }
702
+ return "Workflow created.";
703
+ }
704
+ case "update_workflow": {
705
+ const wf = asRecord(o.workflow);
706
+ return wf.name ? `Updated workflow "${str(wf.name)}".` : "Workflow updated.";
707
+ }
708
+ case "set_workflow_enabled": {
709
+ const wf = asRecord(o.workflow);
710
+ return wf.enabled ? "Workflow enabled." : "Workflow disabled.";
711
+ }
712
+ case "create_api_key":
713
+ return o.prefix ? `Created API key (${str(o.prefix)}\u2026). Copy it from the API Keys page.` : "API key created.";
714
+ case "revoke_api_key":
715
+ return "API key revoked.";
716
+ case "invoke_integration":
717
+ return "Integration action completed.";
718
+ default:
719
+ return "Action completed.";
720
+ }
721
+ }
722
+ function describeFailure(output) {
723
+ const o = asRecord(output);
724
+ const errors = Array.isArray(o.errors) ? o.errors : [];
725
+ const joined = errors.map((e) => typeof e === "string" ? e : str(asRecord(e).message)).filter((m) => m.length > 0).join("; ");
726
+ if (joined) return joined;
727
+ if (typeof o.message === "string" && o.message) return o.message;
728
+ if (o.notFound === true) return "That no longer exists.";
729
+ if (o.conflict === true) return "It changed since it was loaded. Try again.";
730
+ return "The action could not be completed.";
731
+ }
732
+ function isLowBalance(balanceUsd) {
733
+ return balanceUsd != null && balanceUsd < LOW_BALANCE_THRESHOLD;
734
+ }
735
+ function resolveConfirmation(name, result) {
736
+ if (result.ok) {
737
+ const out = asRecord(result.output);
738
+ if (out.ok === false && out.code === "NOT_CONNECTED") {
739
+ return {
740
+ statusText: null,
741
+ error: {
742
+ code: "INTEGRATION_DISCONNECTED",
743
+ message: str(out.message) || "That integration isn't connected."
744
+ }
745
+ };
746
+ }
747
+ if (out.created === false || out.updated === false || out.deleted === false || out.ok === false) {
748
+ return {
749
+ statusText: null,
750
+ error: { code: "TOOL_FAILED", message: describeFailure(result.output) }
751
+ };
752
+ }
753
+ return { statusText: describeOutcome(name, result.output), error: null };
754
+ }
755
+ return {
756
+ statusText: null,
757
+ error: { code: "TOOL_FAILED", message: result.error }
758
+ };
759
+ }
760
+
761
+ // src/assistant/reducer.ts
762
+ var MAX_MESSAGES = 200;
763
+ function capMessages(messages) {
764
+ return messages.length > MAX_MESSAGES ? messages.slice(-MAX_MESSAGES) : messages;
765
+ }
766
+ function initialAssistantState() {
767
+ return {
768
+ ownerId: null,
769
+ threadId: null,
770
+ messages: [],
771
+ status: "idle",
772
+ streamingId: null,
773
+ streamBaseId: null,
774
+ segmentSeq: 0,
775
+ pendingProposals: [],
776
+ usage: null,
777
+ model: null,
778
+ reasoning: null,
779
+ error: null
780
+ };
781
+ }
782
+ function selectVisibleState(state, userId) {
783
+ return state.ownerId === userId ? state : initialAssistantState();
784
+ }
785
+ function dropEmptyStreaming(messages, streamingId) {
786
+ if (!streamingId) return messages;
787
+ const msg = messages.find((m) => m.id === streamingId);
788
+ if (msg && msg.role === "assistant" && msg.text === "") {
789
+ return messages.filter((m) => m.id !== streamingId);
790
+ }
791
+ return messages;
792
+ }
793
+ function appendDelta(messages, streamingId, text) {
794
+ if (!streamingId) return messages;
795
+ return messages.map(
796
+ (m) => m.id === streamingId ? { ...m, text: m.text + text } : m
797
+ );
798
+ }
799
+ function applyStreamEvent(state, event) {
800
+ switch (event.type) {
801
+ case "thread":
802
+ return {
803
+ ...state,
804
+ threadId: event.data.threadId,
805
+ // The model the server actually ran this turn against (lets the picker
806
+ // reflect reality even when the user never explicitly chose one).
807
+ model: event.data.model ?? state.model
808
+ };
809
+ case "delta": {
810
+ if (state.streamingId) {
811
+ return {
812
+ ...state,
813
+ messages: appendDelta(
814
+ state.messages,
815
+ state.streamingId,
816
+ event.data.text
817
+ )
818
+ };
819
+ }
820
+ if (!state.streamBaseId) return state;
821
+ const segmentSeq = state.segmentSeq + 1;
822
+ const id = `${state.streamBaseId}-s${segmentSeq}`;
823
+ return {
824
+ ...state,
825
+ segmentSeq,
826
+ streamingId: id,
827
+ messages: capMessages([
828
+ ...state.messages,
829
+ { id, role: "assistant", text: event.data.text }
830
+ ])
831
+ };
832
+ }
833
+ case "reasoning":
834
+ return {
835
+ ...state,
836
+ reasoning: (state.reasoning ?? "") + event.data.text
837
+ };
838
+ case "tool_call": {
839
+ const trimmed = dropEmptyStreaming(state.messages, state.streamingId);
840
+ const chipId = `tool-${event.data.callId}`;
841
+ if (trimmed.some((m) => m.id === chipId)) {
842
+ return { ...state, messages: trimmed, streamingId: null };
843
+ }
844
+ const chip = {
845
+ id: chipId,
846
+ role: "tool",
847
+ text: "",
848
+ tool: { name: event.data.name, status: "running" }
849
+ };
850
+ return {
851
+ ...state,
852
+ streamingId: null,
853
+ messages: capMessages([...trimmed, chip])
854
+ };
855
+ }
856
+ case "tool_result": {
857
+ const chipId = `tool-${event.data.callId}`;
858
+ const status = event.data.ok ? "ok" : "failed";
859
+ const errText = event.data.ok ? "" : event.data.error?.message ?? "unknown error";
860
+ if (state.messages.some((m) => m.id === chipId)) {
861
+ return {
862
+ ...state,
863
+ messages: state.messages.map(
864
+ (m) => m.id === chipId ? { ...m, text: errText, tool: { name: event.data.name, status } } : m
865
+ )
866
+ };
867
+ }
868
+ const chip = {
869
+ id: chipId,
870
+ role: "tool",
871
+ text: errText,
872
+ tool: { name: event.data.name, status }
873
+ };
874
+ return { ...state, messages: capMessages([...state.messages, chip]) };
875
+ }
876
+ case "tool_proposal": {
877
+ if (state.pendingProposals.some((p) => p.callId === event.data.callId)) {
878
+ return state;
879
+ }
880
+ return {
881
+ ...state,
882
+ pendingProposals: [...state.pendingProposals, event.data]
883
+ };
884
+ }
885
+ case "usage":
886
+ return {
887
+ ...state,
888
+ usage: {
889
+ costUsd: event.data.costUsd,
890
+ balanceUsd: event.data.balanceUsd,
891
+ replayed: event.data.replayed ?? false
892
+ }
893
+ };
894
+ case "done": {
895
+ let messages = dropEmptyStreaming(state.messages, state.streamingId);
896
+ if (event.data.capped) {
897
+ messages = capMessages([
898
+ ...messages,
899
+ {
900
+ id: `cap-${event.data.turnId}`,
901
+ role: "status",
902
+ text: "I reached the step limit for this turn. Ask me to continue and I'll pick up where I left off."
903
+ }
904
+ ]);
905
+ }
906
+ return {
907
+ ...state,
908
+ messages,
909
+ streamingId: null,
910
+ status: state.pendingProposals.length > 0 ? "awaiting_confirm" : "idle"
911
+ };
912
+ }
913
+ case "error": {
914
+ const messages = dropEmptyStreaming(state.messages, state.streamingId);
915
+ return {
916
+ ...state,
917
+ messages,
918
+ streamingId: null,
919
+ status: "idle",
920
+ // A turn that didn't complete cleanly leaves no confirmable action: a
921
+ // proposal buffered before the failure must not stay actionable.
922
+ pendingProposals: [],
923
+ error: { code: event.data.code, message: event.data.message }
924
+ };
925
+ }
926
+ }
927
+ }
928
+ function assistantReducer(state, action) {
929
+ switch (action.type) {
930
+ case "send":
931
+ return {
932
+ ...state,
933
+ messages: capMessages([
934
+ ...state.messages,
935
+ { id: action.messageId, role: "user", text: action.text },
936
+ { id: action.assistantId, role: "assistant", text: "" }
937
+ ]),
938
+ status: "streaming",
939
+ streamingId: action.assistantId,
940
+ // The first bubble is segment 0; tool-finalized segments derive their id
941
+ // from this base, so they stay unique across turns.
942
+ streamBaseId: action.assistantId,
943
+ segmentSeq: 0,
944
+ usage: null,
945
+ reasoning: null,
946
+ error: null
947
+ };
948
+ case "stream":
949
+ return applyStreamEvent(state, action.event);
950
+ case "stream_failed": {
951
+ const messages = dropEmptyStreaming(state.messages, state.streamingId);
952
+ return {
953
+ ...state,
954
+ messages,
955
+ streamingId: null,
956
+ status: "idle",
957
+ // A failed turn leaves no confirmable action (see the `error` case).
958
+ pendingProposals: [],
959
+ error: action.error
960
+ };
961
+ }
962
+ case "proposal_resolved": {
963
+ const pendingProposals = state.pendingProposals.filter(
964
+ (p) => p.callId !== action.callId
965
+ );
966
+ const messages = action.status ? capMessages([...state.messages, action.status]) : state.messages;
967
+ return {
968
+ ...state,
969
+ messages,
970
+ pendingProposals,
971
+ error: action.error,
972
+ status: pendingProposals.length > 0 ? "awaiting_confirm" : state.status === "awaiting_confirm" ? "idle" : state.status
973
+ };
974
+ }
975
+ case "proposal_retry_failed": {
976
+ const pendingProposals = state.pendingProposals.map(
977
+ (p) => p.callId === action.callId ? { ...p, retryError: action.message } : p
978
+ );
979
+ return {
980
+ ...state,
981
+ pendingProposals,
982
+ status: pendingProposals.length > 0 ? "awaiting_confirm" : state.status === "awaiting_confirm" ? "idle" : state.status
983
+ };
984
+ }
985
+ case "stopped":
986
+ return {
987
+ ...state,
988
+ messages: dropEmptyStreaming(state.messages, state.streamingId),
989
+ status: "idle",
990
+ streamingId: null,
991
+ pendingProposals: []
992
+ };
993
+ case "hydrate":
994
+ return {
995
+ ...initialAssistantState(),
996
+ ownerId: action.ownerId,
997
+ threadId: action.threadId,
998
+ messages: action.messages
999
+ };
1000
+ case "restore_history":
1001
+ if (state.ownerId !== action.ownerId || state.threadId !== action.threadId || state.status !== "idle" || state.messages.length > 0 || state.pendingProposals.length > 0) {
1002
+ return state;
1003
+ }
1004
+ return {
1005
+ ...state,
1006
+ messages: capMessages(action.messages),
1007
+ pendingProposals: action.proposals,
1008
+ status: action.proposals.length > 0 ? "awaiting_confirm" : state.status
1009
+ };
1010
+ case "thread_gone":
1011
+ if (state.ownerId !== action.ownerId || state.threadId !== action.threadId || state.status !== "idle" || state.messages.length > 0) {
1012
+ return state;
1013
+ }
1014
+ return { ...state, threadId: null };
1015
+ case "history_failed":
1016
+ if (state.ownerId !== action.ownerId || state.threadId !== action.threadId || state.status !== "idle" || state.messages.length > 0) {
1017
+ return state;
1018
+ }
1019
+ return {
1020
+ ...initialAssistantState(),
1021
+ ownerId: state.ownerId,
1022
+ error: action.error
1023
+ };
1024
+ case "switch_thread":
1025
+ if (state.threadId === action.threadId) return state;
1026
+ return {
1027
+ ...initialAssistantState(),
1028
+ ownerId: state.ownerId,
1029
+ threadId: action.threadId
1030
+ };
1031
+ case "reset":
1032
+ return { ...initialAssistantState(), ownerId: state.ownerId };
1033
+ }
1034
+ }
1035
+
1036
+ // src/assistant/useAssistantChat.ts
1037
+ var EMPTY_IDS = /* @__PURE__ */ new Set();
1038
+ var WORKFLOW_MUTATING_TOOLS = /* @__PURE__ */ new Set([
1039
+ "create_workflow",
1040
+ "author_workflow",
1041
+ "update_workflow",
1042
+ "set_workflow_enabled"
1043
+ ]);
1044
+ function statusMessage(text) {
1045
+ return { id: `status-${uuid()}`, role: "status", text };
1046
+ }
1047
+ function uuid() {
1048
+ return crypto.randomUUID();
1049
+ }
1050
+ function useAssistantChat(userId, options) {
1051
+ const [state, dispatch] = useReducer(
1052
+ assistantReducer,
1053
+ userId,
1054
+ (uid) => {
1055
+ return {
1056
+ ...initialAssistantState(),
1057
+ ownerId: uid,
1058
+ threadId: loadThread(uid).threadId
1059
+ };
1060
+ }
1061
+ );
1062
+ const abortRef = useRef(null);
1063
+ const historyAbortRef = useRef(null);
1064
+ const hydratedUserRef = useRef(userId);
1065
+ const streamSeqRef = useRef(0);
1066
+ const confirmSeqRef = useRef(0);
1067
+ const stateRef = useRef(state);
1068
+ const userIdRef = useRef(userId);
1069
+ stateRef.current = state;
1070
+ userIdRef.current = userId;
1071
+ const client = useAssistantClient();
1072
+ const clientRef = useRef(client);
1073
+ clientRef.current = client;
1074
+ const onWorkflowMutationRef = useRef(options?.onWorkflowMutation);
1075
+ onWorkflowMutationRef.current = options?.onWorkflowMutation;
1076
+ const confirmingRef = useRef(/* @__PURE__ */ new Set());
1077
+ const [confirmingIds, setConfirmingIds] = useState(EMPTY_IDS);
1078
+ const sendingRef = useRef(false);
1079
+ const restoringRef = useRef(false);
1080
+ const [restoring, setRestoring] = useState(false);
1081
+ const setRestoringBoth = useCallback((v) => {
1082
+ restoringRef.current = v;
1083
+ setRestoring(v);
1084
+ }, []);
1085
+ const [selectedModel, setSelectedModel] = useState(
1086
+ () => loadThread(userId).model
1087
+ );
1088
+ const selectedModelRef = useRef(selectedModel);
1089
+ selectedModelRef.current = selectedModel;
1090
+ useEffect(() => {
1091
+ if (hydratedUserRef.current === userId) return;
1092
+ streamSeqRef.current += 1;
1093
+ confirmSeqRef.current += 1;
1094
+ abortRef.current?.abort();
1095
+ historyAbortRef.current?.abort();
1096
+ setRestoringBoth(false);
1097
+ sendingRef.current = false;
1098
+ confirmingRef.current.clear();
1099
+ setConfirmingIds(EMPTY_IDS);
1100
+ hydratedUserRef.current = userId;
1101
+ const nextModel = loadThread(userId).model;
1102
+ selectedModelRef.current = nextModel;
1103
+ setSelectedModel(nextModel);
1104
+ dispatch({
1105
+ type: "hydrate",
1106
+ ownerId: userId,
1107
+ threadId: loadThread(userId).threadId,
1108
+ messages: []
1109
+ });
1110
+ }, [userId, setRestoringBoth]);
1111
+ useEffect(() => {
1112
+ const threadId = loadThread(userId).threadId;
1113
+ if (!userId || !threadId) return;
1114
+ const ac = new AbortController();
1115
+ historyAbortRef.current?.abort();
1116
+ historyAbortRef.current = ac;
1117
+ void clientRef.current.fetchThreadHistory(threadId, ac.signal).then((result) => {
1118
+ if (ac.signal.aborted) return;
1119
+ if (result.status === "ok") {
1120
+ if (result.messages.length > 0 || result.proposals.length > 0) {
1121
+ dispatch({
1122
+ type: "restore_history",
1123
+ ownerId: userId,
1124
+ threadId,
1125
+ messages: result.messages,
1126
+ proposals: result.proposals
1127
+ });
1128
+ }
1129
+ } else if (result.status === "gone") {
1130
+ dispatch({ type: "thread_gone", ownerId: userId, threadId });
1131
+ }
1132
+ }).catch(() => {
1133
+ });
1134
+ return () => ac.abort();
1135
+ }, [userId]);
1136
+ useEffect(() => {
1137
+ if (state.status === "streaming") return;
1138
+ if (state.ownerId !== userIdRef.current) return;
1139
+ saveThread(state.ownerId, {
1140
+ threadId: state.threadId,
1141
+ model: selectedModelRef.current
1142
+ });
1143
+ }, [state.ownerId, state.threadId, state.status]);
1144
+ useEffect(() => {
1145
+ return () => abortRef.current?.abort();
1146
+ }, []);
1147
+ const send = useCallback((message) => {
1148
+ if (sendingRef.current) return;
1149
+ const text = message.trim();
1150
+ const current = stateRef.current;
1151
+ if (current.ownerId !== userIdRef.current) return;
1152
+ if (restoringRef.current) return;
1153
+ if (!text || current.status === "streaming") return;
1154
+ if (current.pendingProposals.length > 0) return;
1155
+ dispatch({
1156
+ type: "send",
1157
+ messageId: uuid(),
1158
+ assistantId: uuid(),
1159
+ text
1160
+ });
1161
+ const seq = ++streamSeqRef.current;
1162
+ const ac = new AbortController();
1163
+ abortRef.current = ac;
1164
+ sendingRef.current = true;
1165
+ clientRef.current.streamChat(
1166
+ {
1167
+ message: text,
1168
+ // null → omit, so the server applies its default model.
1169
+ model: selectedModelRef.current ?? void 0,
1170
+ threadId: current.threadId ?? void 0,
1171
+ turnKey: uuid()
1172
+ },
1173
+ (event) => {
1174
+ if (streamSeqRef.current === seq) dispatch({ type: "stream", event });
1175
+ },
1176
+ ac.signal
1177
+ ).catch((err) => {
1178
+ if (ac.signal.aborted || streamSeqRef.current !== seq) return;
1179
+ dispatch({
1180
+ type: "stream_failed",
1181
+ error: {
1182
+ code: "NETWORK",
1183
+ message: err instanceof Error ? err.message : "The connection failed"
1184
+ }
1185
+ });
1186
+ }).finally(() => {
1187
+ if (streamSeqRef.current === seq) sendingRef.current = false;
1188
+ });
1189
+ }, []);
1190
+ const stop = useCallback(() => {
1191
+ streamSeqRef.current += 1;
1192
+ abortRef.current?.abort();
1193
+ sendingRef.current = false;
1194
+ dispatch({ type: "stopped" });
1195
+ }, []);
1196
+ const confirm = useCallback(async (proposal) => {
1197
+ const pid = proposal.proposalId;
1198
+ if (!pid) {
1199
+ dispatch({
1200
+ type: "proposal_resolved",
1201
+ callId: proposal.callId,
1202
+ status: null,
1203
+ error: {
1204
+ code: "TOOL_FAILED",
1205
+ message: "This action can no longer be confirmed."
1206
+ }
1207
+ });
1208
+ return;
1209
+ }
1210
+ if (confirmingRef.current.has(pid)) return;
1211
+ confirmingRef.current.add(pid);
1212
+ setConfirmingIds(new Set(confirmingRef.current));
1213
+ const seq = confirmSeqRef.current;
1214
+ try {
1215
+ const result = await clientRef.current.confirmProposal(pid);
1216
+ if (confirmSeqRef.current !== seq) return;
1217
+ if (result.ok && result.retryable) {
1218
+ const { error: error2 } = resolveConfirmation(proposal.name, result);
1219
+ dispatch({
1220
+ type: "proposal_retry_failed",
1221
+ callId: proposal.callId,
1222
+ message: error2?.message ?? "Connect the required integration, then confirm again."
1223
+ });
1224
+ return;
1225
+ }
1226
+ const { statusText, error } = resolveConfirmation(proposal.name, result);
1227
+ dispatch({
1228
+ type: "proposal_resolved",
1229
+ callId: proposal.callId,
1230
+ status: statusText ? statusMessage(statusText) : null,
1231
+ error
1232
+ });
1233
+ if (!error && WORKFLOW_MUTATING_TOOLS.has(proposal.name)) {
1234
+ onWorkflowMutationRef.current?.();
1235
+ }
1236
+ } catch (err) {
1237
+ if (confirmSeqRef.current !== seq) return;
1238
+ dispatch({
1239
+ type: "proposal_resolved",
1240
+ callId: proposal.callId,
1241
+ status: null,
1242
+ error: {
1243
+ code: "TOOL_FAILED",
1244
+ message: err instanceof Error ? err.message : "The action could not be completed"
1245
+ }
1246
+ });
1247
+ } finally {
1248
+ if (confirmSeqRef.current === seq) {
1249
+ confirmingRef.current.delete(pid);
1250
+ setConfirmingIds(new Set(confirmingRef.current));
1251
+ }
1252
+ }
1253
+ }, []);
1254
+ const cancel = useCallback((proposal) => {
1255
+ if (proposal.proposalId && confirmingRef.current.has(proposal.proposalId)) {
1256
+ return;
1257
+ }
1258
+ dispatch({
1259
+ type: "proposal_resolved",
1260
+ callId: proposal.callId,
1261
+ status: statusMessage("Action cancelled."),
1262
+ error: null
1263
+ });
1264
+ }, []);
1265
+ const reset = useCallback(() => {
1266
+ streamSeqRef.current += 1;
1267
+ confirmSeqRef.current += 1;
1268
+ abortRef.current?.abort();
1269
+ historyAbortRef.current?.abort();
1270
+ setRestoringBoth(false);
1271
+ sendingRef.current = false;
1272
+ confirmingRef.current.clear();
1273
+ setConfirmingIds(EMPTY_IDS);
1274
+ dispatch({ type: "reset" });
1275
+ }, [setRestoringBoth]);
1276
+ const switchThread = useCallback(
1277
+ (threadId) => {
1278
+ const current = stateRef.current;
1279
+ const uid = userIdRef.current;
1280
+ if (current.ownerId !== uid) return;
1281
+ if (threadId === current.threadId) return;
1282
+ if (current.status === "streaming" || current.pendingProposals.length > 0) {
1283
+ return;
1284
+ }
1285
+ streamSeqRef.current += 1;
1286
+ confirmSeqRef.current += 1;
1287
+ abortRef.current?.abort();
1288
+ sendingRef.current = false;
1289
+ confirmingRef.current.clear();
1290
+ setConfirmingIds(EMPTY_IDS);
1291
+ dispatch({ type: "switch_thread", threadId });
1292
+ setRestoringBoth(true);
1293
+ saveThread(uid, { threadId, model: selectedModelRef.current });
1294
+ const ac = new AbortController();
1295
+ historyAbortRef.current?.abort();
1296
+ historyAbortRef.current = ac;
1297
+ void clientRef.current.fetchThreadHistory(threadId, ac.signal).then((result) => {
1298
+ if (ac.signal.aborted) return;
1299
+ if (result.status === "ok") {
1300
+ if (result.messages.length > 0 || result.proposals.length > 0) {
1301
+ dispatch({
1302
+ type: "restore_history",
1303
+ ownerId: uid,
1304
+ threadId,
1305
+ messages: result.messages,
1306
+ proposals: result.proposals
1307
+ });
1308
+ }
1309
+ } else if (result.status === "gone") {
1310
+ dispatch({ type: "thread_gone", ownerId: uid, threadId });
1311
+ } else {
1312
+ dispatch({
1313
+ type: "history_failed",
1314
+ ownerId: uid,
1315
+ threadId,
1316
+ error: {
1317
+ code: "HISTORY_LOAD_FAILED",
1318
+ message: "Couldn't load that conversation. You're in a new chat \u2014 reopen it from history to try again."
1319
+ }
1320
+ });
1321
+ }
1322
+ setRestoringBoth(false);
1323
+ }).catch(() => {
1324
+ if (!ac.signal.aborted) setRestoringBoth(false);
1325
+ });
1326
+ },
1327
+ [setRestoringBoth]
1328
+ );
1329
+ const setModel = useCallback((model) => {
1330
+ selectedModelRef.current = model;
1331
+ setSelectedModel(model);
1332
+ const currentUserId = userIdRef.current;
1333
+ if (stateRef.current.ownerId === currentUserId) {
1334
+ saveThread(currentUserId, {
1335
+ threadId: stateRef.current.threadId,
1336
+ model
1337
+ });
1338
+ }
1339
+ }, []);
1340
+ useEffect(() => {
1341
+ if (state.error?.code === "MODEL_NOT_ALLOWED" && state.ownerId === userIdRef.current && selectedModelRef.current !== null) {
1342
+ setModel(null);
1343
+ }
1344
+ }, [state.error, state.ownerId, setModel]);
1345
+ const visibleState = selectVisibleState(state, userId);
1346
+ return {
1347
+ state: visibleState,
1348
+ confirmingIds,
1349
+ selectedModel,
1350
+ setModel,
1351
+ send,
1352
+ stop,
1353
+ confirm,
1354
+ cancel,
1355
+ reset,
1356
+ switchThread,
1357
+ restoring
1358
+ };
1359
+ }
1360
+
1361
+ // src/assistant/useAssistantModels.ts
1362
+ import { useEffect as useEffect2, useReducer as useReducer2 } from "react";
1363
+ var EMPTY = { default: null, models: [] };
1364
+ var byClient = /* @__PURE__ */ new WeakMap();
1365
+ function cacheFor(client) {
1366
+ let entry = byClient.get(client);
1367
+ if (!entry) {
1368
+ entry = { cache: null, inflight: null };
1369
+ byClient.set(client, entry);
1370
+ }
1371
+ return entry;
1372
+ }
1373
+ function useAssistantModels() {
1374
+ const client = useAssistantClient();
1375
+ const [, bump] = useReducer2((n) => n + 1, 0);
1376
+ useEffect2(() => {
1377
+ const entry = cacheFor(client);
1378
+ if (entry.cache) return;
1379
+ let active = true;
1380
+ entry.inflight ??= client.fetchModels();
1381
+ void entry.inflight.then((result) => {
1382
+ if (result.ok) entry.cache = result.data;
1383
+ entry.inflight = null;
1384
+ if (active) bump();
1385
+ }).catch(() => {
1386
+ entry.inflight = null;
1387
+ });
1388
+ return () => {
1389
+ active = false;
1390
+ };
1391
+ }, [client]);
1392
+ return cacheFor(client).cache ?? EMPTY;
1393
+ }
1394
+
1395
+ // src/assistant/useAssistantThreads.ts
1396
+ import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef2, useState as useState2 } from "react";
1397
+ function useAssistantThreads(userId) {
1398
+ const client = useAssistantClient();
1399
+ const userRef = useRef2(userId);
1400
+ userRef.current = userId;
1401
+ const clientRef = useRef2(client);
1402
+ clientRef.current = client;
1403
+ const abortRef = useRef2(null);
1404
+ const [state, setState] = useState2(() => ({
1405
+ threads: [],
1406
+ loading: false,
1407
+ loaded: false,
1408
+ ownerUserId: userId,
1409
+ ownerClient: client
1410
+ }));
1411
+ const refresh = useCallback2(() => {
1412
+ const requestedUserId = userRef.current;
1413
+ const requestedClient = clientRef.current;
1414
+ if (!requestedUserId) {
1415
+ setState({
1416
+ threads: [],
1417
+ loading: false,
1418
+ loaded: true,
1419
+ ownerUserId: requestedUserId,
1420
+ ownerClient: requestedClient
1421
+ });
1422
+ return;
1423
+ }
1424
+ abortRef.current?.abort();
1425
+ const ac = new AbortController();
1426
+ abortRef.current = ac;
1427
+ setState((s) => ({
1428
+ ...s,
1429
+ loading: true,
1430
+ ownerUserId: requestedUserId,
1431
+ ownerClient: requestedClient
1432
+ }));
1433
+ const isCurrent = () => !ac.signal.aborted && userRef.current === requestedUserId && clientRef.current === requestedClient;
1434
+ void requestedClient.fetchThreads(ac.signal).then((result) => {
1435
+ if (!isCurrent()) return;
1436
+ setState((s) => ({
1437
+ // null = transient failure: keep the prior list, just drop the spinner.
1438
+ threads: result ?? s.threads,
1439
+ loading: false,
1440
+ loaded: true,
1441
+ ownerUserId: requestedUserId,
1442
+ ownerClient: requestedClient
1443
+ }));
1444
+ }).catch(() => {
1445
+ if (isCurrent()) {
1446
+ setState((s) => ({ ...s, loading: false, loaded: true }));
1447
+ }
1448
+ });
1449
+ }, []);
1450
+ useEffect3(() => () => abortRef.current?.abort(), [userId, client]);
1451
+ const stale = state.ownerUserId !== userId || state.ownerClient !== client;
1452
+ return {
1453
+ threads: stale ? [] : state.threads,
1454
+ loading: stale ? false : state.loading,
1455
+ loaded: stale ? false : state.loaded,
1456
+ refresh
1457
+ };
1458
+ }
1459
+
1460
+ // src/assistant/AssistantDock.tsx
1461
+ import { MessageSquare } from "lucide-react";
1462
+ import {
1463
+ useEffect as useEffect5,
1464
+ useRef as useRef5
1465
+ } from "react";
1466
+
1467
+ // src/assistant/AssistantPanel.tsx
1468
+ import { AgentTimeline, ChatInput } from "@tangle-network/ui/chat";
1469
+ import { History, Minus, Plus, RotateCcw, X } from "lucide-react";
1470
+ import { useState as useState5 } from "react";
1471
+
1472
+ // src/assistant/build-timeline.tsx
1473
+ import { jsx as jsx2 } from "react/jsx-runtime";
1474
+ var TOOL_STATUS = {
1475
+ running: "running",
1476
+ ok: "success",
1477
+ failed: "error"
1478
+ };
1479
+ function assistantIsThinking(state) {
1480
+ if (state.status !== "streaming") return false;
1481
+ const streaming = state.streamingId ? state.messages.find((m) => m.id === state.streamingId) : void 0;
1482
+ return !streaming || streaming.text === "";
1483
+ }
1484
+ function formatUsd(usd) {
1485
+ return `$${usd.toFixed(usd < 0.01 ? 4 : 2)}`;
1486
+ }
1487
+ function messageItem(m, streamingId) {
1488
+ switch (m.role) {
1489
+ case "user":
1490
+ return { id: m.id, kind: "message", role: "user", content: m.text };
1491
+ case "assistant": {
1492
+ const isStreaming = m.id === streamingId;
1493
+ if (m.text === "" && isStreaming) return null;
1494
+ return {
1495
+ id: m.id,
1496
+ kind: "message",
1497
+ role: "assistant",
1498
+ content: m.text,
1499
+ isStreaming
1500
+ };
1501
+ }
1502
+ case "status":
1503
+ return { id: m.id, kind: "status", label: m.text };
1504
+ case "tool": {
1505
+ const name = m.tool?.name ?? "tool";
1506
+ const status = TOOL_STATUS[m.tool?.status ?? "running"];
1507
+ const call = {
1508
+ id: m.id,
1509
+ type: "unknown",
1510
+ label: describeToolActivity(name),
1511
+ status,
1512
+ // The tool message's text carries the error text on failure.
1513
+ detail: status === "error" && m.text ? m.text : void 0
1514
+ };
1515
+ return { id: m.id, kind: "tool", call };
1516
+ }
1517
+ }
1518
+ }
1519
+ function buildAssistantTimeline(state, renderProposal) {
1520
+ const items = [];
1521
+ for (const m of state.messages) {
1522
+ const item = messageItem(m, state.streamingId);
1523
+ if (item) items.push(item);
1524
+ }
1525
+ if (assistantIsThinking(state) && state.reasoning) {
1526
+ items.push({
1527
+ id: "reasoning",
1528
+ kind: "custom",
1529
+ content: /* @__PURE__ */ jsx2("p", { className: "whitespace-pre-wrap px-1 text-muted-foreground text-xs italic", children: state.reasoning })
1530
+ });
1531
+ }
1532
+ if (state.status !== "streaming" && state.usage && state.usage.costUsd != null) {
1533
+ items.push({
1534
+ id: "usage",
1535
+ kind: "status",
1536
+ label: state.usage.replayed ? "Replayed from a previous turn \u2014 no charge." : `This turn cost ${formatUsd(state.usage.costUsd)}.`
1537
+ });
1538
+ }
1539
+ for (const p of state.pendingProposals) {
1540
+ items.push({
1541
+ id: `proposal-${p.callId}`,
1542
+ kind: "custom",
1543
+ content: renderProposal(p)
1544
+ });
1545
+ }
1546
+ return items;
1547
+ }
1548
+
1549
+ // src/assistant/ProposalCard.tsx
1550
+ import { useState as useState3 } from "react";
1551
+ import { jsx as jsx3, jsxs } from "react/jsx-runtime";
1552
+ function ProposalCard({
1553
+ proposal,
1554
+ confirming,
1555
+ onConfirm,
1556
+ onCancel,
1557
+ navigate,
1558
+ renderGraph
1559
+ }) {
1560
+ const view = describeProposal(proposal);
1561
+ const [tab, setTab] = useState3("graph");
1562
+ const isWorkflow = view.preview?.kind === "workflow";
1563
+ const showGraph = isWorkflow && !!renderGraph;
1564
+ return /* @__PURE__ */ jsxs("div", { className: "rounded-lg border border-primary/40 bg-card p-3 text-sm", children: [
1565
+ /* @__PURE__ */ jsx3("p", { className: "font-medium text-foreground", children: view.title }),
1566
+ /* @__PURE__ */ jsx3("p", { className: "text-muted-foreground text-xs", children: "Confirm to run this action on your account." }),
1567
+ view.fields.length > 0 && /* @__PURE__ */ jsx3("dl", { className: "mt-2 space-y-1", children: view.fields.map((f) => /* @__PURE__ */ jsxs("div", { className: "flex gap-2 text-xs", children: [
1568
+ /* @__PURE__ */ jsx3("dt", { className: "shrink-0 text-muted-foreground", children: f.label }),
1569
+ /* @__PURE__ */ jsx3("dd", { className: "truncate text-foreground", title: f.value, children: f.value })
1570
+ ] }, f.label)) }),
1571
+ view.skills && view.skills.length > 0 && /* @__PURE__ */ jsxs("div", { className: "mt-2", children: [
1572
+ /* @__PURE__ */ jsx3("p", { className: "text-muted-foreground text-xs", children: "New skills" }),
1573
+ /* @__PURE__ */ jsx3("ul", { className: "mt-1 space-y-0.5", children: view.skills.map((s) => /* @__PURE__ */ jsxs("li", { className: "text-foreground text-xs", children: [
1574
+ /* @__PURE__ */ jsx3("span", { className: "font-medium", children: s.name }),
1575
+ s.description ? /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
1576
+ " \u2014 ",
1577
+ s.description
1578
+ ] }) : null
1579
+ ] }, s.name)) })
1580
+ ] }),
1581
+ view.preview && /* @__PURE__ */ jsxs("div", { className: "mt-2", children: [
1582
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between", children: [
1583
+ /* @__PURE__ */ jsx3("p", { className: "text-muted-foreground text-xs", children: view.preview.label }),
1584
+ showGraph && /* @__PURE__ */ jsxs("div", { className: "flex gap-2 text-xs", children: [
1585
+ /* @__PURE__ */ jsx3(
1586
+ "button",
1587
+ {
1588
+ type: "button",
1589
+ onClick: () => setTab("graph"),
1590
+ className: tab === "graph" ? "text-foreground" : "text-muted-foreground",
1591
+ children: "Graph"
1592
+ }
1593
+ ),
1594
+ /* @__PURE__ */ jsx3(
1595
+ "button",
1596
+ {
1597
+ type: "button",
1598
+ onClick: () => setTab("yaml"),
1599
+ className: tab === "yaml" ? "text-foreground" : "text-muted-foreground",
1600
+ children: "YAML"
1601
+ }
1602
+ )
1603
+ ] })
1604
+ ] }),
1605
+ showGraph && tab === "graph" ? /* @__PURE__ */ jsx3("div", { className: "mt-1 h-48 overflow-hidden rounded border border-border", children: renderGraph?.(view.preview.content) }) : /* @__PURE__ */ jsx3("pre", { className: "mt-1 max-h-48 overflow-auto rounded border border-border bg-muted/50 p-2 text-xs", children: /* @__PURE__ */ jsx3("code", { children: view.preview.content }) })
1606
+ ] }),
1607
+ proposal.requirements && proposal.requirements.length > 0 && /* @__PURE__ */ jsxs("div", { className: "mt-3 rounded border border-border p-2", children: [
1608
+ /* @__PURE__ */ jsx3("p", { className: "text-muted-foreground text-xs", children: "Integrations" }),
1609
+ /* @__PURE__ */ jsx3("ul", { className: "mt-1 space-y-1", children: proposal.requirements.map((r) => /* @__PURE__ */ jsx3(
1610
+ RequirementRow,
1611
+ {
1612
+ req: r,
1613
+ navigate
1614
+ },
1615
+ `${r.provider}-${r.kind ?? "integration"}`
1616
+ )) }),
1617
+ /* @__PURE__ */ jsx3("p", { className: "mt-1 text-muted-foreground text-xs", children: "Connect the items above, then confirm \u2014 your proposal stays here until you do." })
1618
+ ] }),
1619
+ proposal.retryError && /* @__PURE__ */ jsx3("p", { role: "alert", className: "mt-2 text-destructive text-xs", children: proposal.retryError }),
1620
+ /* @__PURE__ */ jsxs("div", { className: "mt-3 flex gap-2", children: [
1621
+ /* @__PURE__ */ jsx3(
1622
+ "button",
1623
+ {
1624
+ type: "button",
1625
+ onClick: onConfirm,
1626
+ disabled: confirming || !proposal.proposalId,
1627
+ className: "rounded bg-primary px-3 py-1.5 text-primary-foreground text-sm disabled:opacity-50",
1628
+ children: confirming ? "Confirming\u2026" : "Confirm"
1629
+ }
1630
+ ),
1631
+ /* @__PURE__ */ jsx3(
1632
+ "button",
1633
+ {
1634
+ type: "button",
1635
+ onClick: onCancel,
1636
+ disabled: confirming,
1637
+ className: "rounded border border-border px-3 py-1.5 text-foreground text-sm disabled:opacity-50",
1638
+ children: "Cancel"
1639
+ }
1640
+ )
1641
+ ] })
1642
+ ] });
1643
+ }
1644
+ function openConnect(target, navigate) {
1645
+ if (target.startsWith("//")) return;
1646
+ let url;
1647
+ try {
1648
+ url = new URL(target, window.location.origin);
1649
+ } catch {
1650
+ return;
1651
+ }
1652
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
1653
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) {
1654
+ window.open(url.href, "_blank", "noopener,noreferrer");
1655
+ } else if (navigate) {
1656
+ navigate(target);
1657
+ } else {
1658
+ window.location.assign(url.href);
1659
+ }
1660
+ }
1661
+ function RequirementRow({
1662
+ req,
1663
+ navigate
1664
+ }) {
1665
+ const label = providerLabel(req.provider);
1666
+ const isApp = req.kind === "github_app";
1667
+ const kindLabel = isApp ? `${label} App` : label;
1668
+ const statusText = req.connected ? isApp ? "installed" : "connected" : isApp ? "not installed" : "not connected";
1669
+ const canConnect = !req.connected && req.connectUrl !== null;
1670
+ const target = req.connectUrl ?? "/app/integrations";
1671
+ return /* @__PURE__ */ jsxs("li", { className: "flex items-center justify-between gap-2 text-xs", children: [
1672
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
1673
+ /* @__PURE__ */ jsx3(
1674
+ "span",
1675
+ {
1676
+ "aria-hidden": "true",
1677
+ className: `h-2 w-2 rounded-full ${req.connected ? "bg-primary" : "border border-muted-foreground"}`
1678
+ }
1679
+ ),
1680
+ /* @__PURE__ */ jsx3("span", { className: "text-foreground", children: kindLabel }),
1681
+ /* @__PURE__ */ jsx3("span", { className: "text-muted-foreground", children: statusText })
1682
+ ] }),
1683
+ canConnect && /* @__PURE__ */ jsxs(
1684
+ "button",
1685
+ {
1686
+ type: "button",
1687
+ onClick: () => openConnect(target, navigate),
1688
+ className: "shrink-0 text-primary",
1689
+ children: [
1690
+ isApp ? "Install" : "Connect",
1691
+ " \u2192"
1692
+ ]
1693
+ }
1694
+ )
1695
+ ] });
1696
+ }
1697
+
1698
+ // src/assistant/usePanelPrefs.ts
1699
+ import { useCallback as useCallback3, useEffect as useEffect4, useRef as useRef3, useState as useState4 } from "react";
1700
+ var MIN_PANEL_WIDTH = 360;
1701
+ var DEFAULT_PANEL_WIDTH = 448;
1702
+ var MAX_PANEL_WIDTH_FRACTION = 0.95;
1703
+ var MIN_FONT_SCALE = 0.875;
1704
+ var MAX_FONT_SCALE = 1.5;
1705
+ var DEFAULT_FONT_SCALE = 1;
1706
+ var FONT_SCALE_STEP = 0.125;
1707
+ var WIDTH_KEY = "assistant.panel.width";
1708
+ var FONT_SCALE_KEY = "assistant.panel.fontScale";
1709
+ function readNumber(key) {
1710
+ try {
1711
+ const raw = window.localStorage.getItem(key);
1712
+ if (raw == null || raw.trim() === "") return null;
1713
+ const n = Number(raw);
1714
+ return Number.isFinite(n) ? n : null;
1715
+ } catch {
1716
+ return null;
1717
+ }
1718
+ }
1719
+ function writeNumber(key, value) {
1720
+ try {
1721
+ window.localStorage.setItem(key, String(value));
1722
+ } catch {
1723
+ }
1724
+ }
1725
+ function maxPanelWidth() {
1726
+ if (typeof window === "undefined") return 9999;
1727
+ return Math.round(window.innerWidth * MAX_PANEL_WIDTH_FRACTION);
1728
+ }
1729
+ function clampWidth(value) {
1730
+ return Math.min(
1731
+ Math.max(Math.round(value), MIN_PANEL_WIDTH),
1732
+ maxPanelWidth()
1733
+ );
1734
+ }
1735
+ function clampScale(value) {
1736
+ const stepped = Math.round(value / FONT_SCALE_STEP) * FONT_SCALE_STEP;
1737
+ return Math.min(Math.max(stepped, MIN_FONT_SCALE), MAX_FONT_SCALE);
1738
+ }
1739
+ function usePanelWidth() {
1740
+ const [width, setWidthState] = useState4(DEFAULT_PANEL_WIDTH);
1741
+ const [maxWidth, setMaxWidth] = useState4(() => maxPanelWidth());
1742
+ const desiredRef = useRef3(DEFAULT_PANEL_WIDTH);
1743
+ useEffect4(() => {
1744
+ setMaxWidth(maxPanelWidth());
1745
+ const stored = readNumber(WIDTH_KEY);
1746
+ if (stored != null) {
1747
+ desiredRef.current = Math.max(Math.round(stored), MIN_PANEL_WIDTH);
1748
+ setWidthState(clampWidth(stored));
1749
+ }
1750
+ }, []);
1751
+ useEffect4(() => {
1752
+ const onResize = () => {
1753
+ setMaxWidth(maxPanelWidth());
1754
+ setWidthState(clampWidth(desiredRef.current));
1755
+ };
1756
+ window.addEventListener("resize", onResize);
1757
+ return () => window.removeEventListener("resize", onResize);
1758
+ }, []);
1759
+ const setWidth = useCallback3((next) => {
1760
+ const clamped = clampWidth(next);
1761
+ desiredRef.current = clamped;
1762
+ writeNumber(WIDTH_KEY, clamped);
1763
+ setWidthState(clamped);
1764
+ }, []);
1765
+ const previewWidth = useCallback3((next) => {
1766
+ setWidthState(clampWidth(next));
1767
+ }, []);
1768
+ const nudgeWidth = useCallback3((deltaPx) => {
1769
+ const clamped = clampWidth(desiredRef.current + deltaPx);
1770
+ desiredRef.current = clamped;
1771
+ writeNumber(WIDTH_KEY, clamped);
1772
+ setWidthState(clamped);
1773
+ }, []);
1774
+ return { width, maxWidth, setWidth, previewWidth, nudgeWidth };
1775
+ }
1776
+ function useFontScale() {
1777
+ const [scale, setScaleState] = useState4(DEFAULT_FONT_SCALE);
1778
+ useEffect4(() => {
1779
+ const stored = readNumber(FONT_SCALE_KEY);
1780
+ if (stored != null) setScaleState(clampScale(stored));
1781
+ }, []);
1782
+ const step = useCallback3((delta) => {
1783
+ setScaleState((prev) => {
1784
+ const clamped = clampScale(prev + delta);
1785
+ writeNumber(FONT_SCALE_KEY, clamped);
1786
+ return clamped;
1787
+ });
1788
+ }, []);
1789
+ return {
1790
+ scale,
1791
+ increase: () => step(FONT_SCALE_STEP),
1792
+ decrease: () => step(-FONT_SCALE_STEP),
1793
+ canIncrease: scale < MAX_FONT_SCALE - 1e-9,
1794
+ canDecrease: scale > MIN_FONT_SCALE + 1e-9
1795
+ };
1796
+ }
1797
+ function useIsDesktop() {
1798
+ const [isDesktop, setIsDesktop] = useState4(
1799
+ () => typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(min-width: 768px)").matches : true
1800
+ );
1801
+ useEffect4(() => {
1802
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
1803
+ return;
1804
+ }
1805
+ const mq = window.matchMedia("(min-width: 768px)");
1806
+ const update = () => setIsDesktop(mq.matches);
1807
+ update();
1808
+ mq.addEventListener("change", update);
1809
+ return () => mq.removeEventListener("change", update);
1810
+ }, []);
1811
+ return isDesktop;
1812
+ }
1813
+
1814
+ // src/assistant/AssistantPanel.tsx
1815
+ import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1816
+ var EMPTY_STATE = "Ask me to create a workflow, check your usage, or manage your API keys.";
1817
+ function defaultFormatMoney(usd) {
1818
+ if (usd == null) return "\u2014";
1819
+ return new Intl.NumberFormat("en-US", {
1820
+ style: "currency",
1821
+ currency: "USD"
1822
+ }).format(usd);
1823
+ }
1824
+ function AssistantPanel({
1825
+ chat,
1826
+ userId,
1827
+ onClose,
1828
+ navigate,
1829
+ balanceUsd = null,
1830
+ formatMoney = defaultFormatMoney,
1831
+ renderGraph
1832
+ }) {
1833
+ const models = useAssistantModels();
1834
+ const threads = useAssistantThreads(userId);
1835
+ const font = useFontScale();
1836
+ const [historyOpen, setHistoryOpen] = useState5(false);
1837
+ const { state } = chat;
1838
+ const effectiveBalance = state.usage?.balanceUsd ?? balanceUsd;
1839
+ const errorView = state.error ? presentError(state.error.code, state.error.message) : null;
1840
+ const low = isLowBalance(effectiveBalance) && !errorView;
1841
+ const streaming = state.status === "streaming";
1842
+ const renderProposal = (proposal) => /* @__PURE__ */ jsx4(
1843
+ ProposalCard,
1844
+ {
1845
+ proposal,
1846
+ confirming: proposal.proposalId ? chat.confirmingIds.has(proposal.proposalId) : false,
1847
+ onConfirm: () => chat.confirm(proposal),
1848
+ onCancel: () => chat.cancel(proposal),
1849
+ navigate,
1850
+ renderGraph
1851
+ }
1852
+ );
1853
+ const items = buildAssistantTimeline(state, renderProposal);
1854
+ const openHistory = () => {
1855
+ setHistoryOpen((v) => {
1856
+ const next = !v;
1857
+ if (next) threads.refresh();
1858
+ return next;
1859
+ });
1860
+ };
1861
+ return /* @__PURE__ */ jsxs2("div", { className: "flex h-full flex-col bg-background", children: [
1862
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center justify-between border-border border-b px-4 py-3", children: [
1863
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-baseline gap-2", children: [
1864
+ /* @__PURE__ */ jsx4("span", { className: "font-medium text-foreground text-sm", children: "Assistant" }),
1865
+ /* @__PURE__ */ jsx4(
1866
+ "span",
1867
+ {
1868
+ "aria-label": "Your credit balance",
1869
+ className: "text-muted-foreground text-xs",
1870
+ children: formatMoney(effectiveBalance)
1871
+ }
1872
+ )
1873
+ ] }),
1874
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-1", children: [
1875
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center", role: "group", "aria-label": "Text size", children: [
1876
+ /* @__PURE__ */ jsx4(
1877
+ "button",
1878
+ {
1879
+ type: "button",
1880
+ onClick: font.decrease,
1881
+ disabled: !font.canDecrease,
1882
+ "aria-label": "Decrease text size",
1883
+ className: "rounded p-1 text-muted-foreground hover:text-foreground disabled:opacity-40",
1884
+ children: /* @__PURE__ */ jsx4(Minus, { className: "h-4 w-4" })
1885
+ }
1886
+ ),
1887
+ /* @__PURE__ */ jsx4(
1888
+ "button",
1889
+ {
1890
+ type: "button",
1891
+ onClick: font.increase,
1892
+ disabled: !font.canIncrease,
1893
+ "aria-label": "Increase text size",
1894
+ className: "rounded p-1 text-muted-foreground hover:text-foreground disabled:opacity-40",
1895
+ children: /* @__PURE__ */ jsx4(Plus, { className: "h-4 w-4" })
1896
+ }
1897
+ )
1898
+ ] }),
1899
+ /* @__PURE__ */ jsx4(
1900
+ "button",
1901
+ {
1902
+ type: "button",
1903
+ onClick: openHistory,
1904
+ "aria-label": "Chat history",
1905
+ "aria-pressed": historyOpen,
1906
+ className: "rounded p-1 text-muted-foreground hover:text-foreground",
1907
+ children: /* @__PURE__ */ jsx4(History, { className: "h-4 w-4" })
1908
+ }
1909
+ ),
1910
+ /* @__PURE__ */ jsx4(
1911
+ "button",
1912
+ {
1913
+ type: "button",
1914
+ onClick: chat.reset,
1915
+ "aria-label": "New chat",
1916
+ className: "rounded p-1 text-muted-foreground hover:text-foreground",
1917
+ children: /* @__PURE__ */ jsx4(RotateCcw, { className: "h-4 w-4" })
1918
+ }
1919
+ ),
1920
+ /* @__PURE__ */ jsx4(
1921
+ "button",
1922
+ {
1923
+ type: "button",
1924
+ onClick: onClose,
1925
+ "aria-label": "Close assistant",
1926
+ className: "rounded p-1 text-muted-foreground hover:text-foreground",
1927
+ children: /* @__PURE__ */ jsx4(X, { className: "h-4 w-4" })
1928
+ }
1929
+ )
1930
+ ] })
1931
+ ] }),
1932
+ models.models.length > 0 && /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-2 border-border border-b px-4 py-2", children: [
1933
+ /* @__PURE__ */ jsx4(
1934
+ "label",
1935
+ {
1936
+ htmlFor: "assistant-model",
1937
+ className: "text-muted-foreground text-xs",
1938
+ children: "Model"
1939
+ }
1940
+ ),
1941
+ /* @__PURE__ */ jsxs2(
1942
+ "select",
1943
+ {
1944
+ id: "assistant-model",
1945
+ value: chat.selectedModel ?? "",
1946
+ onChange: (e) => chat.setModel(e.target.value || null),
1947
+ className: "rounded border border-border bg-card px-2 py-1 text-foreground text-xs",
1948
+ children: [
1949
+ /* @__PURE__ */ jsx4("option", { value: "", children: models.default ?? "Default" }),
1950
+ models.models.map((m) => /* @__PURE__ */ jsx4("option", { value: m.slug, children: m.label }, m.slug))
1951
+ ]
1952
+ }
1953
+ )
1954
+ ] }),
1955
+ historyOpen && /* @__PURE__ */ jsx4("div", { className: "max-h-48 overflow-y-auto border-border border-b", children: threads.threads.length === 0 ? /* @__PURE__ */ jsx4("p", { className: "px-4 py-3 text-muted-foreground text-xs", children: threads.loaded ? "No past conversations." : "Loading\u2026" }) : /* @__PURE__ */ jsx4("ul", { children: threads.threads.map((t) => /* @__PURE__ */ jsx4("li", { children: /* @__PURE__ */ jsx4(
1956
+ "button",
1957
+ {
1958
+ type: "button",
1959
+ onClick: () => {
1960
+ chat.switchThread(t.id);
1961
+ setHistoryOpen(false);
1962
+ },
1963
+ className: "block w-full truncate px-4 py-2 text-left text-foreground text-xs hover:bg-muted/50",
1964
+ children: t.title ?? "Untitled conversation"
1965
+ }
1966
+ ) }, t.id)) }) }),
1967
+ /* @__PURE__ */ jsx4(
1968
+ "div",
1969
+ {
1970
+ role: "log",
1971
+ "aria-label": "Conversation",
1972
+ "aria-live": "polite",
1973
+ className: "min-h-0 flex-1 overflow-y-auto px-2 py-3",
1974
+ style: { fontSize: `${font.scale}rem` },
1975
+ children: /* @__PURE__ */ jsx4(
1976
+ AgentTimeline,
1977
+ {
1978
+ items,
1979
+ isThinking: assistantIsThinking(state),
1980
+ emptyState: /* @__PURE__ */ jsx4("p", { className: "px-4 py-8 text-center text-muted-foreground text-sm", children: EMPTY_STATE })
1981
+ }
1982
+ )
1983
+ }
1984
+ ),
1985
+ errorView && /* @__PURE__ */ jsxs2(
1986
+ "div",
1987
+ {
1988
+ role: "alert",
1989
+ className: "mx-4 mb-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm",
1990
+ children: [
1991
+ /* @__PURE__ */ jsx4("p", { className: "text-foreground", children: errorView.message }),
1992
+ errorView.cta && /* @__PURE__ */ jsxs2(
1993
+ "button",
1994
+ {
1995
+ type: "button",
1996
+ onClick: () => navigate?.(errorView.cta?.to ?? ""),
1997
+ className: "mt-1 text-primary text-xs",
1998
+ children: [
1999
+ errorView.cta.label,
2000
+ " \u2192"
2001
+ ]
2002
+ }
2003
+ )
2004
+ ]
2005
+ }
2006
+ ),
2007
+ low && /* @__PURE__ */ jsxs2(
2008
+ "div",
2009
+ {
2010
+ role: "status",
2011
+ className: "mx-4 mb-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm",
2012
+ children: [
2013
+ /* @__PURE__ */ jsx4("p", { className: "text-foreground", children: "Your credit balance is running low." }),
2014
+ /* @__PURE__ */ jsx4(
2015
+ "button",
2016
+ {
2017
+ type: "button",
2018
+ onClick: () => navigate?.("/app/billing"),
2019
+ className: "mt-1 text-primary text-xs",
2020
+ children: "Add credits \u2192"
2021
+ }
2022
+ )
2023
+ ]
2024
+ }
2025
+ ),
2026
+ /* @__PURE__ */ jsx4("div", { className: "border-border border-t p-2", children: /* @__PURE__ */ jsx4(
2027
+ ChatInput,
2028
+ {
2029
+ onSend: (message) => chat.send(message),
2030
+ onCancel: chat.stop,
2031
+ isStreaming: streaming,
2032
+ disabled: chat.restoring || state.status === "awaiting_confirm",
2033
+ placeholder: "Message the assistant\u2026",
2034
+ inputLabel: null,
2035
+ idleStatus: null,
2036
+ streamingStatus: null
2037
+ }
2038
+ ) })
2039
+ ] });
2040
+ }
2041
+
2042
+ // src/assistant/launcher.tsx
2043
+ import {
2044
+ createContext as createContext2,
2045
+ useCallback as useCallback4,
2046
+ useContext as useContext2,
2047
+ useMemo,
2048
+ useState as useState6
2049
+ } from "react";
2050
+ import { jsx as jsx5 } from "react/jsx-runtime";
2051
+ var AssistantLauncherContext = createContext2(null);
2052
+ function AssistantLauncherProvider({
2053
+ children
2054
+ }) {
2055
+ const [open, setOpen] = useState6(false);
2056
+ const [seed, setSeed] = useState6(null);
2057
+ const openAssistant = useCallback4((next) => {
2058
+ if (next != null) setSeed(next);
2059
+ setOpen(true);
2060
+ }, []);
2061
+ const closeAssistant = useCallback4(() => setOpen(false), []);
2062
+ const clearSeed = useCallback4(() => setSeed(null), []);
2063
+ const value = useMemo(
2064
+ () => ({ open, seed, openAssistant, closeAssistant, clearSeed }),
2065
+ [open, seed, openAssistant, closeAssistant, clearSeed]
2066
+ );
2067
+ return /* @__PURE__ */ jsx5(AssistantLauncherContext.Provider, { value, children });
2068
+ }
2069
+ function useAssistantLauncher() {
2070
+ const ctx = useContext2(AssistantLauncherContext);
2071
+ if (!ctx) {
2072
+ throw new Error(
2073
+ "useAssistantLauncher must be used within an AssistantLauncherProvider"
2074
+ );
2075
+ }
2076
+ return ctx;
2077
+ }
2078
+
2079
+ // src/assistant/ResizeHandle.tsx
2080
+ import { useRef as useRef4 } from "react";
2081
+ import { jsx as jsx6 } from "react/jsx-runtime";
2082
+ function ResizeHandle({
2083
+ width,
2084
+ maxWidth,
2085
+ onPreview,
2086
+ onCommit,
2087
+ onNudge
2088
+ }) {
2089
+ const dragRef = useRef4(null);
2090
+ const onPointerDown = (e) => {
2091
+ if (e.button !== 0) return;
2092
+ e.preventDefault();
2093
+ e.currentTarget.setPointerCapture(e.pointerId);
2094
+ dragRef.current = {
2095
+ startX: e.clientX,
2096
+ startWidth: width,
2097
+ lastWidth: width
2098
+ };
2099
+ };
2100
+ const onPointerMove = (e) => {
2101
+ const drag = dragRef.current;
2102
+ if (!drag) return;
2103
+ const next = drag.startWidth + (drag.startX - e.clientX);
2104
+ if (Math.abs(next - drag.lastWidth) < 1) return;
2105
+ drag.lastWidth = next;
2106
+ onPreview(next);
2107
+ };
2108
+ const endDrag = (e) => {
2109
+ const drag = dragRef.current;
2110
+ if (!drag) return;
2111
+ dragRef.current = null;
2112
+ if (e.currentTarget.hasPointerCapture(e.pointerId)) {
2113
+ e.currentTarget.releasePointerCapture(e.pointerId);
2114
+ }
2115
+ onCommit(drag.lastWidth);
2116
+ };
2117
+ const onKeyDown = (e) => {
2118
+ const STEP = 24;
2119
+ if (e.key === "ArrowLeft") {
2120
+ e.preventDefault();
2121
+ onNudge(STEP);
2122
+ } else if (e.key === "ArrowRight") {
2123
+ e.preventDefault();
2124
+ onNudge(-STEP);
2125
+ }
2126
+ };
2127
+ return (
2128
+ // biome-ignore lint/a11y/useSemanticElements: a focusable drag handle is an ARIA window-splitter (role=separator); no native HTML element provides this.
2129
+ /* @__PURE__ */ jsx6(
2130
+ "div",
2131
+ {
2132
+ role: "separator",
2133
+ "aria-orientation": "vertical",
2134
+ "aria-label": "Resize assistant panel",
2135
+ "aria-valuemin": MIN_PANEL_WIDTH,
2136
+ "aria-valuemax": maxWidth,
2137
+ "aria-valuenow": width,
2138
+ tabIndex: 0,
2139
+ onPointerDown,
2140
+ onPointerMove,
2141
+ onPointerUp: endDrag,
2142
+ onPointerCancel: endDrag,
2143
+ onKeyDown,
2144
+ className: "group absolute inset-y-0 left-0 z-10 flex w-2 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center focus:outline-none",
2145
+ children: /* @__PURE__ */ jsx6(
2146
+ "span",
2147
+ {
2148
+ "aria-hidden": "true",
2149
+ className: "h-10 w-1 rounded-full bg-border transition-colors group-hover:bg-primary group-focus:bg-primary"
2150
+ }
2151
+ )
2152
+ }
2153
+ )
2154
+ );
2155
+ }
2156
+
2157
+ // src/assistant/AssistantDock.tsx
2158
+ import { Fragment, jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
2159
+ function focusableWithin(container) {
2160
+ const selector = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
2161
+ return Array.from(container.querySelectorAll(selector)).filter(
2162
+ (el) => el.getClientRects().length > 0
2163
+ );
2164
+ }
2165
+ function AssistantDock({
2166
+ userId,
2167
+ navigate,
2168
+ balanceUsd = null,
2169
+ formatMoney,
2170
+ renderGraph,
2171
+ onWorkflowMutation
2172
+ }) {
2173
+ const { open, openAssistant, closeAssistant } = useAssistantLauncher();
2174
+ const chat = useAssistantChat(userId, { onWorkflowMutation });
2175
+ const isDesktop = useIsDesktop();
2176
+ const { width, maxWidth, setWidth, previewWidth, nudgeWidth } = usePanelWidth();
2177
+ const launcherRef = useRef5(null);
2178
+ const dialogRef = useRef5(null);
2179
+ const returnFocusRef = useRef5(null);
2180
+ const wasOpenRef = useRef5(false);
2181
+ useEffect5(() => {
2182
+ if (!open) return;
2183
+ const onKeyDown = (e) => {
2184
+ if (e.key === "Escape") closeAssistant();
2185
+ };
2186
+ document.addEventListener("keydown", onKeyDown);
2187
+ return () => document.removeEventListener("keydown", onKeyDown);
2188
+ }, [open, closeAssistant]);
2189
+ useEffect5(() => {
2190
+ if (open) {
2191
+ if (!wasOpenRef.current && !returnFocusRef.current) {
2192
+ returnFocusRef.current = document.activeElement;
2193
+ }
2194
+ wasOpenRef.current = true;
2195
+ const el = dialogRef.current;
2196
+ if (el) (focusableWithin(el)[0] ?? el).focus();
2197
+ } else if (wasOpenRef.current) {
2198
+ wasOpenRef.current = false;
2199
+ const target = returnFocusRef.current?.isConnected ? returnFocusRef.current : launcherRef.current;
2200
+ target?.focus();
2201
+ returnFocusRef.current = null;
2202
+ }
2203
+ }, [open]);
2204
+ const openDialog = () => {
2205
+ returnFocusRef.current = document.activeElement;
2206
+ openAssistant();
2207
+ };
2208
+ if (!open) {
2209
+ return /* @__PURE__ */ jsx7(
2210
+ "button",
2211
+ {
2212
+ ref: launcherRef,
2213
+ type: "button",
2214
+ onClick: openDialog,
2215
+ "aria-label": "Open assistant",
2216
+ className: "fixed right-4 bottom-4 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-colors hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring",
2217
+ children: /* @__PURE__ */ jsx7(MessageSquare, { className: "h-6 w-6" })
2218
+ }
2219
+ );
2220
+ }
2221
+ const trapTab = (e) => {
2222
+ if (e.key !== "Tab") return;
2223
+ const dialog = dialogRef.current;
2224
+ if (!dialog) return;
2225
+ const focusables = focusableWithin(dialog);
2226
+ if (focusables.length === 0) {
2227
+ e.preventDefault();
2228
+ dialog.focus();
2229
+ return;
2230
+ }
2231
+ const first = focusables[0];
2232
+ const last = focusables[focusables.length - 1];
2233
+ const active = document.activeElement;
2234
+ const inside = active instanceof Node && dialog.contains(active);
2235
+ if (e.shiftKey) {
2236
+ if (!inside || active === first || active === dialog) {
2237
+ e.preventDefault();
2238
+ last.focus();
2239
+ }
2240
+ } else if (!inside || active === last) {
2241
+ e.preventDefault();
2242
+ first.focus();
2243
+ }
2244
+ };
2245
+ return /* @__PURE__ */ jsxs3(Fragment, { children: [
2246
+ /* @__PURE__ */ jsx7(
2247
+ "div",
2248
+ {
2249
+ "aria-hidden": "true",
2250
+ className: "fixed inset-0 z-40 bg-black/40",
2251
+ onClick: () => closeAssistant()
2252
+ }
2253
+ ),
2254
+ /* @__PURE__ */ jsxs3(
2255
+ "div",
2256
+ {
2257
+ ref: dialogRef,
2258
+ role: "dialog",
2259
+ "aria-label": "Assistant",
2260
+ "aria-modal": "true",
2261
+ tabIndex: -1,
2262
+ onKeyDown: trapTab,
2263
+ style: isDesktop ? { width: `${width}px` } : void 0,
2264
+ className: "fixed inset-y-0 right-0 z-50 flex w-full flex-col border-border border-l shadow-xl focus:outline-none",
2265
+ children: [
2266
+ /* @__PURE__ */ jsx7(
2267
+ AssistantPanel,
2268
+ {
2269
+ chat,
2270
+ userId,
2271
+ onClose: () => closeAssistant(),
2272
+ navigate,
2273
+ balanceUsd,
2274
+ formatMoney,
2275
+ renderGraph
2276
+ },
2277
+ userId ?? "anon"
2278
+ ),
2279
+ isDesktop && /* @__PURE__ */ jsx7(
2280
+ ResizeHandle,
2281
+ {
2282
+ width,
2283
+ maxWidth,
2284
+ onPreview: previewWidth,
2285
+ onCommit: setWidth,
2286
+ onNudge: nudgeWidth
2287
+ }
2288
+ )
2289
+ ]
2290
+ }
2291
+ )
2292
+ ] });
2293
+ }
2294
+ export {
2295
+ AssistantClientProvider,
2296
+ AssistantDock,
2297
+ AssistantLauncherProvider,
2298
+ AssistantPanel,
2299
+ ProposalCard,
2300
+ createAssistantClient,
2301
+ useAssistantChat,
2302
+ useAssistantClient,
2303
+ useAssistantLauncher,
2304
+ useAssistantModels,
2305
+ useAssistantThreads
2306
+ };