@tangle-network/agent-app 0.42.15 → 0.42.18

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