iris-chatbot 0.2.4

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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/bin/iris.mjs +267 -0
  4. package/package.json +61 -0
  5. package/template/LICENSE +21 -0
  6. package/template/README.md +49 -0
  7. package/template/eslint.config.mjs +18 -0
  8. package/template/next.config.ts +7 -0
  9. package/template/package-lock.json +9193 -0
  10. package/template/package.json +46 -0
  11. package/template/postcss.config.mjs +7 -0
  12. package/template/public/file.svg +1 -0
  13. package/template/public/globe.svg +1 -0
  14. package/template/public/next.svg +1 -0
  15. package/template/public/vercel.svg +1 -0
  16. package/template/public/window.svg +1 -0
  17. package/template/src/app/api/chat/route.ts +2445 -0
  18. package/template/src/app/api/connections/models/route.ts +255 -0
  19. package/template/src/app/api/connections/test/route.ts +124 -0
  20. package/template/src/app/api/local-sync/route.ts +74 -0
  21. package/template/src/app/api/tool-approval/route.ts +47 -0
  22. package/template/src/app/favicon.ico +0 -0
  23. package/template/src/app/globals.css +808 -0
  24. package/template/src/app/layout.tsx +74 -0
  25. package/template/src/app/page.tsx +444 -0
  26. package/template/src/components/ChatView.tsx +1537 -0
  27. package/template/src/components/Composer.tsx +160 -0
  28. package/template/src/components/MapView.tsx +244 -0
  29. package/template/src/components/MessageCard.tsx +955 -0
  30. package/template/src/components/SearchModal.tsx +72 -0
  31. package/template/src/components/SettingsModal.tsx +1257 -0
  32. package/template/src/components/Sidebar.tsx +153 -0
  33. package/template/src/components/TopBar.tsx +164 -0
  34. package/template/src/lib/connections.ts +275 -0
  35. package/template/src/lib/data.ts +324 -0
  36. package/template/src/lib/db.ts +49 -0
  37. package/template/src/lib/hooks.ts +76 -0
  38. package/template/src/lib/local-sync.ts +192 -0
  39. package/template/src/lib/memory.ts +695 -0
  40. package/template/src/lib/model-presets.ts +251 -0
  41. package/template/src/lib/store.ts +36 -0
  42. package/template/src/lib/tooling/approvals.ts +78 -0
  43. package/template/src/lib/tooling/providers/anthropic.ts +155 -0
  44. package/template/src/lib/tooling/providers/ollama.ts +73 -0
  45. package/template/src/lib/tooling/providers/openai.ts +267 -0
  46. package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
  47. package/template/src/lib/tooling/providers/types.ts +44 -0
  48. package/template/src/lib/tooling/registry.ts +103 -0
  49. package/template/src/lib/tooling/runtime.ts +189 -0
  50. package/template/src/lib/tooling/safety.ts +165 -0
  51. package/template/src/lib/tooling/tools/apps.ts +108 -0
  52. package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
  53. package/template/src/lib/tooling/tools/communication.ts +883 -0
  54. package/template/src/lib/tooling/tools/files.ts +395 -0
  55. package/template/src/lib/tooling/tools/music.ts +988 -0
  56. package/template/src/lib/tooling/tools/notes.ts +461 -0
  57. package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
  58. package/template/src/lib/tooling/tools/numbers.ts +175 -0
  59. package/template/src/lib/tooling/tools/schedule.ts +579 -0
  60. package/template/src/lib/tooling/tools/system.ts +142 -0
  61. package/template/src/lib/tooling/tools/web.ts +212 -0
  62. package/template/src/lib/tooling/tools/workflow.ts +218 -0
  63. package/template/src/lib/tooling/types.ts +27 -0
  64. package/template/src/lib/types.ts +309 -0
  65. package/template/src/lib/utils.ts +108 -0
  66. package/template/tsconfig.json +34 -0
@@ -0,0 +1,955 @@
1
+ "use client";
2
+
3
+ import ReactMarkdown from "react-markdown";
4
+ import rehypeKatex from "rehype-katex";
5
+ import remarkGfm from "remark-gfm";
6
+ import remarkMath from "remark-math";
7
+ import {
8
+ ChevronDown,
9
+ ChevronUp,
10
+ Check,
11
+ Copy,
12
+ Pencil,
13
+ PlusCircle,
14
+ X,
15
+ } from "lucide-react";
16
+ import { memo, useMemo, useState } from "react";
17
+ import type { MessageNode, Thread, ToolApproval, ToolEvent } from "../lib/types";
18
+ import { splitContentAndSources } from "../lib/utils";
19
+
20
+ const MAX_VISIBLE_TOOL_ITEMS = 8;
21
+ const HIDDEN_TIMELINE_TOOLS = new Set(["tooling", "workflow_run"]);
22
+ const TOOL_TIMELINE_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
23
+ month: "short",
24
+ day: "numeric",
25
+ hour: "numeric",
26
+ minute: "2-digit",
27
+ });
28
+
29
+ function MarkdownTable({
30
+ children,
31
+ }: {
32
+ children: React.ReactNode;
33
+ }) {
34
+ return (
35
+ <div className="md-table-wrap">
36
+ <table>{children}</table>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ function normalizeMathDelimiters(content: string) {
42
+ // Convert TeX delimiters to remark-math compatible delimiters.
43
+ return content
44
+ .replace(/\\\\\[([\s\S]+?)\\\\\]/g, (_, expr: string) => `$$${expr}$$`)
45
+ .replace(/\\\[([\s\S]+?)\\\]/g, (_, expr: string) => `$$${expr}$$`)
46
+ .replace(/\\\\\(([\s\S]+?)\\\\\)/g, (_, expr: string) => `$${expr}$`)
47
+ .replace(/\\\(([\s\S]+?)\\\)/g, (_, expr: string) => `$${expr}$`);
48
+ }
49
+
50
+ function normalizeMarkdownStructure(content: string) {
51
+ if (!content) {
52
+ return "";
53
+ }
54
+
55
+ const lines = content.replace(/\r\n?/g, "\n").split("\n");
56
+ const normalized: string[] = [];
57
+
58
+ for (let index = 0; index < lines.length; index += 1) {
59
+ const line = lines[index];
60
+ const trimmed = line.trim();
61
+
62
+ if (!trimmed) {
63
+ normalized.push("");
64
+ continue;
65
+ }
66
+
67
+ const isHeading = /^#{1,6}\s+/.test(trimmed);
68
+ const isListItem = /^([-*+]|\d+\.)\s+/.test(trimmed);
69
+ const isQuotedOrCode = /^(```|>|---|~~~)/.test(trimmed);
70
+ const previous = index > 0 ? lines[index - 1].trim() : "";
71
+ const next = index < lines.length - 1 ? lines[index + 1].trim() : "";
72
+ const nextIsList = /^([-*+]|\d+\.)\s+/.test(next);
73
+ const isStandaloneTitleCaseLine =
74
+ !isHeading &&
75
+ !isListItem &&
76
+ !isQuotedOrCode &&
77
+ trimmed.length >= 3 &&
78
+ trimmed.length <= 72 &&
79
+ !/[.!?;:]$/.test(trimmed) &&
80
+ /^[A-Z0-9][A-Za-z0-9 '"’&/(),-]+$/.test(trimmed) &&
81
+ previous === "" &&
82
+ (nextIsList || next === "");
83
+
84
+ if (isStandaloneTitleCaseLine) {
85
+ normalized.push(`### ${trimmed}`);
86
+ continue;
87
+ }
88
+
89
+ const labelMatch = trimmed.match(/^([A-Z][A-Za-z0-9 '&/(),-]{2,40}):\s+(.+)$/);
90
+ if (labelMatch && !isListItem && !isHeading) {
91
+ normalized.push(`**${labelMatch[1].trim()}:** ${labelMatch[2].trim()}`);
92
+ continue;
93
+ }
94
+
95
+ normalized.push(line);
96
+ }
97
+
98
+ return normalized.join("\n").replace(/\n{3,}/g, "\n\n").trim();
99
+ }
100
+
101
+ function stabilizeStreamingMarkdown(content: string) {
102
+ let stable = content;
103
+ const fenceMatches = stable.match(/```/g);
104
+ const fenceCount = fenceMatches ? fenceMatches.length : 0;
105
+ if (fenceCount % 2 === 1) {
106
+ stable += "\n```";
107
+ }
108
+ const boldAsteriskCount = (stable.match(/\*\*/g) || []).length;
109
+ if (boldAsteriskCount % 2 === 1) {
110
+ stable += "**";
111
+ }
112
+ const boldUnderscoreCount = (stable.match(/__/g) || []).length;
113
+ if (boldUnderscoreCount % 2 === 1) {
114
+ stable += "__";
115
+ }
116
+ const withoutFences = stable.replace(/```[\s\S]*?```/g, "");
117
+ const inlineCodeTickCount = (withoutFences.match(/`/g) || []).length;
118
+ if (inlineCodeTickCount % 2 === 1) {
119
+ stable += "`";
120
+ }
121
+ const lastLine = stable.split("\n").pop()?.trim() || "";
122
+ if ((/^#{1,6}\s+\S/.test(lastLine) || /\|/.test(lastLine)) && !stable.endsWith("\n")) {
123
+ stable += "\n";
124
+ }
125
+ return stable;
126
+ }
127
+
128
+ function parsePayload(payloadJson?: string): Record<string, unknown> | null {
129
+ if (!payloadJson) {
130
+ return null;
131
+ }
132
+
133
+ try {
134
+ const parsed = JSON.parse(payloadJson) as unknown;
135
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
136
+ return parsed as Record<string, unknown>;
137
+ }
138
+ } catch {
139
+ return null;
140
+ }
141
+
142
+ return null;
143
+ }
144
+
145
+ function humanizeToolName(toolName: string): string {
146
+ const labels: Record<string, string> = {
147
+ file_list: "Browse Files",
148
+ file_move: "Move Item",
149
+ file_copy: "Copy Item",
150
+ file_mkdir: "Create Folder",
151
+ file_delete_to_trash: "Move to Trash",
152
+ notes_create_or_append: "Update Note",
153
+ notes_find: "Search Notes",
154
+ app_open: "Open App",
155
+ app_focus: "Focus App",
156
+ calendar_create_event: "Create Calendar Event",
157
+ calendar_list_events: "Check Calendar",
158
+ reminder_create: "Create Reminder",
159
+ reminder_list: "Check Reminders",
160
+ music_play: "Play Music",
161
+ music_get_now_playing: "Now Playing",
162
+ music_pause: "Pause Music",
163
+ music_next: "Next Track",
164
+ music_previous: "Previous Track",
165
+ music_set_volume: "Set Music Volume",
166
+ system_set_volume: "Set System Volume",
167
+ numbers_read_selection: "Read Numbers Selection",
168
+ numbers_set_cell: "Edit Numbers Cell",
169
+ };
170
+
171
+ if (labels[toolName]) {
172
+ return labels[toolName];
173
+ }
174
+
175
+ return toolName
176
+ .replace(/[_-]+/g, " ")
177
+ .replace(/\b\w/g, (char) => char.toUpperCase());
178
+ }
179
+
180
+ function shortenPath(input: string): string {
181
+ return input.replace(/^\/Users\/[^/]+/, "~");
182
+ }
183
+
184
+ function getString(object: Record<string, unknown>, key: string): string | null {
185
+ const value = object[key];
186
+ return typeof value === "string" && value.trim() ? value.trim() : null;
187
+ }
188
+
189
+ function getNumber(object: Record<string, unknown>, key: string): number | null {
190
+ const value = object[key];
191
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
192
+ }
193
+
194
+ function getBoolean(object: Record<string, unknown>, key: string): boolean | null {
195
+ const value = object[key];
196
+ return typeof value === "boolean" ? value : null;
197
+ }
198
+
199
+ function formatCount(value: number): string {
200
+ return value.toLocaleString("en-US");
201
+ }
202
+
203
+ function joinDetailParts(parts: Array<string | null | undefined>): string | undefined {
204
+ const compact = parts.filter((value): value is string => Boolean(value));
205
+ if (compact.length === 0) {
206
+ return undefined;
207
+ }
208
+ return compact.join(" · ");
209
+ }
210
+
211
+ function formatDateTime(value: string | null): string | null {
212
+ if (!value) {
213
+ return null;
214
+ }
215
+ const parsed = new Date(value);
216
+ if (Number.isNaN(parsed.getTime())) {
217
+ return value;
218
+ }
219
+ return TOOL_TIMELINE_DATE_FORMATTER.format(parsed);
220
+ }
221
+
222
+ function formatTrackLabel(title: string | null, artist: string | null): string {
223
+ if (title && artist) {
224
+ return `"${title}" by ${artist}`;
225
+ }
226
+ if (title) {
227
+ return `"${title}"`;
228
+ }
229
+ if (artist) {
230
+ return `track by ${artist}`;
231
+ }
232
+ return "track";
233
+ }
234
+
235
+ function cleanTechnicalError(message: string): string {
236
+ return message
237
+ .trim()
238
+ .replace(/^\d+:\d+:\s*/i, "")
239
+ .replace(/^execution error:\s*/i, "")
240
+ .replace(/\s+\(-\d+\)\s*$/, "")
241
+ .replace(/^error:\s*/i, "")
242
+ .replace(/\s+/g, " ");
243
+ }
244
+
245
+ function summarizeToolError(toolName: string, rawError: string): {
246
+ title: string;
247
+ detail?: string;
248
+ } {
249
+ const cleaned = cleanTechnicalError(rawError);
250
+ const normalized = cleaned.toLowerCase().replace(/[\u2019]/g, "'");
251
+
252
+ if (toolName === "calendar_create_event" && normalized.includes("can't get date")) {
253
+ const requestedDateMatch = cleaned.match(/can't get date "([^"]+)"/i);
254
+ const requestedDate = requestedDateMatch?.[1] ? formatDateTime(requestedDateMatch[1]) : null;
255
+ return {
256
+ title: "Could not add the calendar event",
257
+ detail:
258
+ requestedDate
259
+ ? `Calendar could not read the date/time (${requestedDate}). Try a format like "Feb 12 at 9:00 AM".`
260
+ : 'Calendar could not read the date/time. Try a format like "Feb 12 at 9:00 AM".',
261
+ };
262
+ }
263
+
264
+ if (normalized.includes("missing required")) {
265
+ return {
266
+ title: "Missing required information",
267
+ detail: "The action did not include all required fields.",
268
+ };
269
+ }
270
+
271
+ if (normalized.includes("timed out")) {
272
+ return {
273
+ title: "Timed out",
274
+ detail: "The app did not respond in time.",
275
+ };
276
+ }
277
+
278
+ return {
279
+ title: "Could not complete",
280
+ detail: cleaned || "An unknown error occurred.",
281
+ };
282
+ }
283
+
284
+ function summarizeToolCall(toolName: string, payload: Record<string, unknown> | null): {
285
+ title: string;
286
+ detail?: string;
287
+ } {
288
+ if (!payload) {
289
+ return { title: "Preparing action" };
290
+ }
291
+
292
+ if (toolName === "file_list") {
293
+ const targetPath = getString(payload, "path");
294
+ const pattern = getString(payload, "pattern");
295
+ const recursive = getBoolean(payload, "recursive");
296
+ const parts = [
297
+ recursive ? "Including subfolders" : "Current folder only",
298
+ pattern ? `Filter: ${pattern}` : null,
299
+ ].filter(Boolean);
300
+ return {
301
+ title: targetPath ? `Searching ${shortenPath(targetPath)}` : "Searching files",
302
+ detail: parts.length > 0 ? parts.join(" · ") : undefined,
303
+ };
304
+ }
305
+
306
+ if (toolName === "workflow_run") {
307
+ const summary = getString(payload, "summary");
308
+ if (summary) {
309
+ return { title: summary };
310
+ }
311
+ }
312
+
313
+ if (toolName === "music_play") {
314
+ const query = getString(payload, "query");
315
+ const title = getString(payload, "title");
316
+ const artist = getString(payload, "artist");
317
+ const requested = title || artist ? formatTrackLabel(title, artist) : query ? `"${query}"` : null;
318
+ return {
319
+ title: requested ? `Starting ${requested}` : "Starting playback",
320
+ };
321
+ }
322
+
323
+ if (toolName === "calendar_create_event") {
324
+ const title = getString(payload, "title");
325
+ const start = formatDateTime(getString(payload, "start"));
326
+ return {
327
+ title: title ? `Adding "${title}" to Calendar` : "Adding calendar event",
328
+ detail: start ? `When: ${start}` : undefined,
329
+ };
330
+ }
331
+
332
+ if (toolName === "reminder_create") {
333
+ const title = getString(payload, "title");
334
+ const due = formatDateTime(getString(payload, "due"));
335
+ return {
336
+ title: title ? `Adding reminder "${title}"` : "Adding reminder",
337
+ detail: due ? `Due: ${due}` : undefined,
338
+ };
339
+ }
340
+
341
+ if (toolName === "music_set_volume" || toolName === "system_set_volume") {
342
+ const level = getNumber(payload, "level");
343
+ return {
344
+ title: level !== null ? `Setting volume to ${level}%` : "Setting volume",
345
+ };
346
+ }
347
+
348
+ if (toolName === "file_move" || toolName === "file_copy") {
349
+ const source = getString(payload, "source");
350
+ const destination = getString(payload, "destination");
351
+ return {
352
+ title: toolName === "file_move" ? "Preparing move" : "Preparing copy",
353
+ detail:
354
+ source && destination
355
+ ? `${shortenPath(source)} -> ${shortenPath(destination)}`
356
+ : undefined,
357
+ };
358
+ }
359
+
360
+ if (toolName === "file_mkdir") {
361
+ const targetPath = getString(payload, "path");
362
+ return {
363
+ title: targetPath ? `Creating ${shortenPath(targetPath)}` : "Creating folder",
364
+ };
365
+ }
366
+
367
+ return { title: "Preparing action" };
368
+ }
369
+
370
+ function summarizeToolResult(toolName: string, payload: Record<string, unknown> | null): {
371
+ title: string;
372
+ detail?: string;
373
+ } {
374
+ if (!payload) {
375
+ return { title: "Completed successfully" };
376
+ }
377
+
378
+ const error = getString(payload, "error");
379
+ if (error) {
380
+ return summarizeToolError(toolName, error);
381
+ }
382
+
383
+ if (toolName === "music_play") {
384
+ const playing = getBoolean(payload, "playing");
385
+ const matched = getBoolean(payload, "matched");
386
+ const title = getString(payload, "title");
387
+ const artist = getString(payload, "artist");
388
+ const album = getString(payload, "album");
389
+ const reason = getString(payload, "reason");
390
+ const nearestTitle = getString(payload, "matchedTitle");
391
+ const nearestArtist = getString(payload, "matchedArtist");
392
+
393
+ if (playing) {
394
+ if (matched === false) {
395
+ return {
396
+ title: "Playing a close match",
397
+ detail: joinDetailParts([
398
+ `Now playing ${formatTrackLabel(title, artist)}`,
399
+ album ? `Album: ${album}` : null,
400
+ reason,
401
+ ]),
402
+ };
403
+ }
404
+
405
+ return {
406
+ title: `Now playing ${formatTrackLabel(title, artist)}`,
407
+ detail: album ? `Album: ${album}` : undefined,
408
+ };
409
+ }
410
+
411
+ return {
412
+ title: "Could not start playback",
413
+ detail: joinDetailParts([
414
+ reason ?? "No matching track was found.",
415
+ nearestTitle ? `Closest match: ${formatTrackLabel(nearestTitle, nearestArtist)}` : null,
416
+ ]),
417
+ };
418
+ }
419
+
420
+ if (toolName === "music_get_now_playing") {
421
+ const state = getString(payload, "state");
422
+ const title = getString(payload, "title");
423
+ const artist = getString(payload, "artist");
424
+ const album = getString(payload, "album");
425
+ if (state === "stopped") {
426
+ return { title: "Nothing is currently playing" };
427
+ }
428
+ return {
429
+ title: `Now playing ${formatTrackLabel(title, artist)}`,
430
+ detail: joinDetailParts([state ? `State: ${state}` : null, album ? `Album: ${album}` : null]),
431
+ };
432
+ }
433
+
434
+ if (toolName === "calendar_create_event" && payload.created === true) {
435
+ const title = getString(payload, "title");
436
+ const calendar = getString(payload, "calendar");
437
+ const location = getString(payload, "location");
438
+ const start = formatDateTime(getString(payload, "startResolved") ?? getString(payload, "start"));
439
+ const end = formatDateTime(getString(payload, "endResolved") ?? getString(payload, "end"));
440
+ return {
441
+ title: title
442
+ ? `Added "${title}"${calendar ? ` to ${calendar}` : ""}`
443
+ : "Calendar event added",
444
+ detail: joinDetailParts([
445
+ start ? `Starts: ${start}` : null,
446
+ end ? `Ends: ${end}` : null,
447
+ location ? `Location: ${location}` : null,
448
+ ]),
449
+ };
450
+ }
451
+
452
+ if (toolName === "calendar_list_events") {
453
+ const count = getNumber(payload, "count");
454
+ const from = formatDateTime(getString(payload, "from"));
455
+ const to = formatDateTime(getString(payload, "to"));
456
+ if (count !== null) {
457
+ return {
458
+ title:
459
+ count === 0
460
+ ? "No events found"
461
+ : `Found ${formatCount(count)} calendar ${count === 1 ? "event" : "events"}`,
462
+ detail: joinDetailParts([from ? `From: ${from}` : null, to ? `To: ${to}` : null]),
463
+ };
464
+ }
465
+ }
466
+
467
+ if (toolName === "reminder_create" && payload.created === true) {
468
+ const title = getString(payload, "title");
469
+ const list = getString(payload, "list");
470
+ const dueResolved = getString(payload, "dueResolved");
471
+ return {
472
+ title: title ? `Added reminder "${title}"` : "Reminder added",
473
+ detail: joinDetailParts([
474
+ list ? `List: ${list}` : null,
475
+ dueResolved ? `Due: ${dueResolved}` : null,
476
+ ]),
477
+ };
478
+ }
479
+
480
+ if (toolName === "reminder_list") {
481
+ const count = getNumber(payload, "count");
482
+ const list = getString(payload, "list");
483
+ if (count !== null) {
484
+ return {
485
+ title:
486
+ count === 0
487
+ ? "No reminders found"
488
+ : `Found ${formatCount(count)} reminder${count === 1 ? "" : "s"}`,
489
+ detail: list ? `List: ${list}` : undefined,
490
+ };
491
+ }
492
+ }
493
+
494
+ if (toolName === "music_set_volume" || toolName === "system_set_volume") {
495
+ const level = getNumber(payload, "level");
496
+ const target = getString(payload, "target");
497
+ if (level !== null) {
498
+ return {
499
+ title: `Volume set to ${level}%`,
500
+ detail: target ? `Target: ${target}` : undefined,
501
+ };
502
+ }
503
+ }
504
+
505
+ if (toolName === "system_open_url" && payload.opened === true) {
506
+ const url = getString(payload, "url");
507
+ return {
508
+ title: "Opened URL",
509
+ detail: url ?? undefined,
510
+ };
511
+ }
512
+
513
+ if (toolName === "file_list") {
514
+ const count = getNumber(payload, "count");
515
+ const returnedCount = getNumber(payload, "returnedCount");
516
+ const truncated = getBoolean(payload, "truncated");
517
+ if (count !== null && returnedCount !== null) {
518
+ return {
519
+ title:
520
+ count === 0
521
+ ? "No items found"
522
+ : `Found ${formatCount(count)} ${count === 1 ? "item" : "items"}`,
523
+ detail: truncated
524
+ ? `Showing first ${formatCount(returnedCount)} results`
525
+ : `${formatCount(returnedCount)} results shown`,
526
+ };
527
+ }
528
+ }
529
+
530
+ if (toolName === "file_move" && payload.moved === true) {
531
+ const destination = getString(payload, "destination");
532
+ return {
533
+ title: "Moved successfully",
534
+ detail: destination ? shortenPath(destination) : undefined,
535
+ };
536
+ }
537
+
538
+ if (toolName === "file_copy" && payload.copied === true) {
539
+ const destination = getString(payload, "destination");
540
+ return {
541
+ title: "Copied successfully",
542
+ detail: destination ? shortenPath(destination) : undefined,
543
+ };
544
+ }
545
+
546
+ if (toolName === "file_mkdir" && payload.created === true) {
547
+ const targetPath = getString(payload, "path");
548
+ return {
549
+ title: "Folder created",
550
+ detail: targetPath ? shortenPath(targetPath) : undefined,
551
+ };
552
+ }
553
+
554
+ return { title: "Completed successfully" };
555
+ }
556
+
557
+ function sourceBadgeLabel(url: string, title?: string): string {
558
+ if (title && title.trim()) {
559
+ return title.trim();
560
+ }
561
+ try {
562
+ const parsed = new URL(url);
563
+ return parsed.hostname.replace(/^www\./, "") || url;
564
+ } catch {
565
+ return url;
566
+ }
567
+ }
568
+
569
+ function getTimelineVisual(event: ToolEvent): {
570
+ chipLabel: string;
571
+ chipClassName: string;
572
+ title: string;
573
+ detail?: string;
574
+ } {
575
+ const payload = parsePayload(event.payloadJson);
576
+ const cleanedMessage = event.message?.replace(/^(started|running|warning|info|completed):\s*/i, "");
577
+
578
+ if (event.stage === "call") {
579
+ const summary = summarizeToolCall(event.toolName, payload);
580
+ return {
581
+ chipLabel: "Queued",
582
+ chipClassName: "border-[var(--border)] text-[var(--text-muted)]",
583
+ title: summary.title,
584
+ detail: summary.detail,
585
+ };
586
+ }
587
+
588
+ if (event.stage === "progress") {
589
+ return {
590
+ chipLabel: "Working",
591
+ chipClassName: "border-[var(--accent)]/50 text-[var(--text-secondary)]",
592
+ title: cleanedMessage || "Working on it",
593
+ };
594
+ }
595
+
596
+ const summary = summarizeToolResult(event.toolName, payload);
597
+ const isError = event.message?.toLowerCase().includes("failure") || Boolean(payload?.error);
598
+ return {
599
+ chipLabel: isError ? "Needs attention" : "Done",
600
+ chipClassName: isError
601
+ ? "border-[var(--danger)]/50 text-[var(--danger)]"
602
+ : "border-[var(--accent)]/50 text-[var(--accent)]",
603
+ title: summary.title,
604
+ detail: summary.detail,
605
+ };
606
+ }
607
+
608
+ function MessageCard({
609
+ message,
610
+ onAddThread,
611
+ threads,
612
+ baseThreadId,
613
+ activeThreadId,
614
+ onSelectThread,
615
+ onDeleteThread,
616
+ isStreaming,
617
+ toolEvents,
618
+ approvals,
619
+ onResolveApproval,
620
+ approvalBusyIds,
621
+ }: {
622
+ message: MessageNode;
623
+ onAddThread: (message: MessageNode) => void;
624
+ threads: Thread[];
625
+ baseThreadId: string | null;
626
+ activeThreadId: string | null;
627
+ onSelectThread: (id: string) => void;
628
+ onDeleteThread: (threadId: string, fallbackThreadId: string | null) => void;
629
+ isStreaming?: boolean;
630
+ toolEvents?: ToolEvent[];
631
+ approvals?: ToolApproval[];
632
+ onResolveApproval?: (approvalId: string, decision: "approve" | "deny") => void;
633
+ approvalBusyIds?: Record<string, boolean>;
634
+ }) {
635
+ const [copied, setCopied] = useState(false);
636
+ const [threadAdded, setThreadAdded] = useState(false);
637
+ const [assistantCollapsed, setAssistantCollapsed] = useState(false);
638
+ const [threadEditMode, setThreadEditMode] = useState(false);
639
+ const isAssistant = message.role === "assistant";
640
+ const canAddThread = threads.length < 4;
641
+ const shelfThreads = [
642
+ ...(baseThreadId ? [{ id: baseThreadId, title: "Thread 1", isBase: true }] : []),
643
+ ...threads.map((thread) => ({
644
+ id: thread.id,
645
+ title: thread.title,
646
+ isBase: false,
647
+ })),
648
+ ];
649
+ const showShelf = isAssistant && threads.length > 0;
650
+ const canEditThreads = isAssistant && shelfThreads.length >= 2;
651
+ const { content: messageTextContent, sources: messageSources } = useMemo(
652
+ () => splitContentAndSources(message.content || ""),
653
+ [message.content],
654
+ );
655
+ const isStreamingPlaceholder = isAssistant && isStreaming && !messageTextContent;
656
+ const assistantContent = useMemo(
657
+ () => normalizeMathDelimiters(normalizeMarkdownStructure(messageTextContent)),
658
+ [messageTextContent],
659
+ );
660
+ const renderedAssistantContent = isStreaming
661
+ ? stabilizeStreamingMarkdown(assistantContent)
662
+ : assistantContent;
663
+
664
+ const timelineItems = useMemo(
665
+ () =>
666
+ (toolEvents ?? [])
667
+ .filter((event) => !HIDDEN_TIMELINE_TOOLS.has(event.toolName))
668
+ .slice()
669
+ .sort((a, b) => a.createdAt - b.createdAt),
670
+ [toolEvents],
671
+ );
672
+ const visibleTimelineItems = useMemo(
673
+ () => timelineItems.slice(-MAX_VISIBLE_TOOL_ITEMS),
674
+ [timelineItems],
675
+ );
676
+ const hiddenTimelineCount = Math.max(0, timelineItems.length - visibleTimelineItems.length);
677
+ const approvalItems = useMemo(
678
+ () =>
679
+ (approvals ?? [])
680
+ .filter((approval) => approval.status === "requested")
681
+ .slice()
682
+ .sort((a, b) => a.requestedAt - b.requestedAt),
683
+ [approvals],
684
+ );
685
+ const collapsedPreviewText = useMemo(() => {
686
+ if (messageTextContent.trim()) {
687
+ return messageTextContent.trim();
688
+ }
689
+
690
+ const firstToolEvent = timelineItems[0];
691
+ if (firstToolEvent) {
692
+ const summary = getTimelineVisual(firstToolEvent);
693
+ return `${humanizeToolName(firstToolEvent.toolName)}: ${summary.title}`;
694
+ }
695
+
696
+ const firstApproval = approvalItems[0];
697
+ if (firstApproval) {
698
+ const summary = summarizeToolCall(firstApproval.toolName, parsePayload(firstApproval.argsJson));
699
+ return `${humanizeToolName(firstApproval.toolName)}: ${summary.title}`;
700
+ }
701
+
702
+ return "Working on your request.";
703
+ }, [messageTextContent, timelineItems, approvalItems]);
704
+
705
+ return (
706
+ <div className="message-stack group">
707
+ <div className={`${isAssistant ? "assistant-card" : "message-card user"} group`}>
708
+ {isAssistant ? (
709
+ <div className="assistant-collapse-row">
710
+ <button
711
+ className="assistant-collapse-toggle"
712
+ onClick={() => setAssistantCollapsed((prev) => !prev)}
713
+ aria-label={assistantCollapsed ? "Expand message" : "Collapse message"}
714
+ >
715
+ {assistantCollapsed ? (
716
+ <ChevronDown className="h-4 w-4" />
717
+ ) : (
718
+ <ChevronUp className="h-4 w-4" />
719
+ )}
720
+ </button>
721
+ </div>
722
+ ) : null}
723
+ <div className="message-content">
724
+ {message.role === "assistant" && assistantCollapsed ? (
725
+ <p
726
+ className="assistant-collapsed-preview"
727
+ onClick={() => setAssistantCollapsed(false)}
728
+ role="button"
729
+ tabIndex={0}
730
+ onKeyDown={(event) => {
731
+ if (event.key === "Enter" || event.key === " ") {
732
+ event.preventDefault();
733
+ setAssistantCollapsed(false);
734
+ }
735
+ }}
736
+ >
737
+ {collapsedPreviewText.slice(0, 160)}
738
+ {collapsedPreviewText.length > 160 ? "..." : ""}
739
+ </p>
740
+ ) : message.role === "assistant" ? (
741
+ isStreamingPlaceholder ? (
742
+ <div
743
+ className="message-loading-spinner"
744
+ role="status"
745
+ aria-label="Assistant is responding"
746
+ />
747
+ ) : (
748
+ <ReactMarkdown
749
+ remarkPlugins={[remarkGfm, remarkMath]}
750
+ rehypePlugins={[rehypeKatex]}
751
+ components={{
752
+ table: ({ children }) => <MarkdownTable>{children}</MarkdownTable>,
753
+ }}
754
+ >
755
+ {renderedAssistantContent}
756
+ </ReactMarkdown>
757
+ )
758
+ ) : (
759
+ <p>{messageTextContent}</p>
760
+ )}
761
+
762
+ {message.role === "assistant" && !assistantCollapsed && messageSources.length > 0 ? (
763
+ <div className="source-badge-row" aria-label="Sources">
764
+ {messageSources.map((source, index) => (
765
+ <a
766
+ key={`${source.url}-${index}`}
767
+ className="source-badge"
768
+ href={source.url}
769
+ target="_blank"
770
+ rel="noreferrer noopener"
771
+ title={source.title || source.url}
772
+ >
773
+ <span className="source-badge-index">{index + 1}</span>
774
+ <span className="source-badge-title">{sourceBadgeLabel(source.url, source.title)}</span>
775
+ </a>
776
+ ))}
777
+ </div>
778
+ ) : null}
779
+
780
+ {isAssistant && !assistantCollapsed && (timelineItems.length > 0 || approvalItems.length > 0) ? (
781
+ <div className="assistant-tool-timeline mt-4 space-y-2 rounded-2xl border border-[var(--border)] bg-[var(--panel-2)] p-3">
782
+ {hiddenTimelineCount > 0 ? (
783
+ <div className="rounded-xl border border-[var(--border)] bg-[var(--panel)] px-3 py-2 text-xs text-[var(--text-muted)]">
784
+ Showing latest {visibleTimelineItems.length} of {timelineItems.length} tool actions
785
+ </div>
786
+ ) : null}
787
+
788
+ {visibleTimelineItems.map((event) => {
789
+ const visual = getTimelineVisual(event);
790
+ return (
791
+ <div key={event.id} className="rounded-xl border border-[var(--border)] bg-[var(--panel)] px-3 py-2.5">
792
+ <div className="flex items-center justify-between gap-3">
793
+ <div className="text-[11px] font-medium text-[var(--text-secondary)]">
794
+ {humanizeToolName(event.toolName)}
795
+ </div>
796
+ <span className={`rounded-full border px-2 py-0.5 text-[10px] ${visual.chipClassName}`}>
797
+ {visual.chipLabel}
798
+ </span>
799
+ </div>
800
+ <div className="mt-1 text-sm text-[var(--text-primary)]">{visual.title}</div>
801
+ {visual.detail ? (
802
+ <div className="mt-1 text-xs text-[var(--text-muted)]">{visual.detail}</div>
803
+ ) : null}
804
+ </div>
805
+ );
806
+ })}
807
+
808
+ {approvalItems.map((approval) => {
809
+ const isPending = approval.status === "requested";
810
+ const isBusy = Boolean(approvalBusyIds?.[approval.id]);
811
+ const callSummary = summarizeToolCall(approval.toolName, parsePayload(approval.argsJson));
812
+ const approvalLabel =
813
+ approval.status === "requested"
814
+ ? "Needs approval"
815
+ : approval.status === "approved"
816
+ ? "Approved"
817
+ : approval.status === "denied"
818
+ ? "Declined"
819
+ : "Timed out";
820
+ return (
821
+ <div key={approval.id} className="rounded-xl border border-[var(--border)] bg-[var(--panel)] px-3 py-2.5">
822
+ <div className="flex items-center justify-between gap-3">
823
+ <div className="text-[11px] font-medium text-[var(--text-secondary)]">
824
+ {humanizeToolName(approval.toolName)}
825
+ </div>
826
+ <span className="rounded-full border border-[var(--border)] px-2 py-0.5 text-[10px] text-[var(--text-muted)]">
827
+ {approvalLabel}
828
+ </span>
829
+ </div>
830
+ <div className="mt-1 text-sm text-[var(--text-primary)]">{callSummary.title}</div>
831
+ {callSummary.detail ? (
832
+ <div className="mt-1 text-xs text-[var(--text-muted)]">{callSummary.detail}</div>
833
+ ) : null}
834
+ {approval.reason ? (
835
+ <div className="mt-1 text-xs text-[var(--text-muted)]">{approval.reason}</div>
836
+ ) : null}
837
+ <div className="mt-2 flex items-center gap-2">
838
+ {isPending && onResolveApproval ? (
839
+ <>
840
+ <button
841
+ className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-1 text-[10px] text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
842
+ onClick={() => onResolveApproval(approval.id, "approve")}
843
+ disabled={isBusy}
844
+ >
845
+ Approve
846
+ </button>
847
+ <button
848
+ className="rounded-full border border-[var(--border)] bg-[var(--panel-2)] px-3 py-1 text-[10px] text-[var(--text-secondary)] hover:border-[var(--border-strong)]"
849
+ onClick={() => onResolveApproval(approval.id, "deny")}
850
+ disabled={isBusy}
851
+ >
852
+ Deny
853
+ </button>
854
+ </>
855
+ ) : null}
856
+ </div>
857
+ </div>
858
+ );
859
+ })}
860
+
861
+ </div>
862
+ ) : null}
863
+ </div>
864
+ {showShelf && !assistantCollapsed ? (
865
+ <div className="thread-shelf">
866
+ {shelfThreads.map((thread) => (
867
+ <div key={thread.id} className="thread-box-wrap">
868
+ <button
869
+ className={`thread-box ${
870
+ activeThreadId === thread.id ? "active" : ""
871
+ }`}
872
+ onClick={() => onSelectThread(thread.id)}
873
+ >
874
+ {thread.title}
875
+ </button>
876
+ {threadEditMode && !thread.isBase ? (
877
+ <button
878
+ className="thread-delete"
879
+ onClick={(event) => {
880
+ event.stopPropagation();
881
+ onDeleteThread(thread.id, baseThreadId);
882
+ }}
883
+ aria-label={`Delete ${thread.title}`}
884
+ >
885
+ <X className="h-3 w-3" />
886
+ </button>
887
+ ) : null}
888
+ </div>
889
+ ))}
890
+ </div>
891
+ ) : null}
892
+ </div>
893
+ <div
894
+ className={`message-actions ${isAssistant ? "assistant opacity-0 pointer-events-none transition-opacity duration-150 group-hover:opacity-100 group-hover:pointer-events-auto" : "user"}`}
895
+ >
896
+ {isAssistant ? (
897
+ <button
898
+ className={`flex items-center justify-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition ${
899
+ canAddThread
900
+ ? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
901
+ : "opacity-60 cursor-not-allowed"
902
+ }`}
903
+ onClick={async () => {
904
+ if (!canAddThread) return;
905
+ await onAddThread(message);
906
+ setThreadAdded(true);
907
+ window.setTimeout(() => setThreadAdded(false), 1200);
908
+ }}
909
+ disabled={!canAddThread}
910
+ >
911
+ {threadAdded ? (
912
+ <Check className="h-4 w-4 text-[var(--accent)]" />
913
+ ) : (
914
+ <>
915
+ <PlusCircle className="h-4 w-4" />
916
+ Thread
917
+ </>
918
+ )}
919
+ </button>
920
+ ) : null}
921
+ <button
922
+ className={`flex items-center gap-2 rounded-full border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-muted)] transition hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)] ${
923
+ isAssistant ? "" : "opacity-0 group-hover:opacity-100"
924
+ }`}
925
+ onClick={async () => {
926
+ await navigator.clipboard.writeText(messageTextContent);
927
+ setCopied(true);
928
+ window.setTimeout(() => setCopied(false), 1200);
929
+ }}
930
+ >
931
+ {copied ? (
932
+ <Check className="h-4 w-4 text-[var(--accent)]" />
933
+ ) : (
934
+ <Copy className="h-4 w-4" />
935
+ )}
936
+ </button>
937
+ {canEditThreads ? (
938
+ <button
939
+ className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${
940
+ threadEditMode
941
+ ? "border-[var(--accent)] text-[var(--text-primary)]"
942
+ : "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
943
+ }`}
944
+ onClick={() => setThreadEditMode((prev) => !prev)}
945
+ aria-label="Edit threads"
946
+ >
947
+ <Pencil className="h-4 w-4" />
948
+ </button>
949
+ ) : null}
950
+ </div>
951
+ </div>
952
+ );
953
+ }
954
+
955
+ export default memo(MessageCard);