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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/iris.mjs +267 -0
- package/package.json +61 -0
- package/template/LICENSE +21 -0
- package/template/README.md +49 -0
- package/template/eslint.config.mjs +18 -0
- package/template/next.config.ts +7 -0
- package/template/package-lock.json +9193 -0
- package/template/package.json +46 -0
- package/template/postcss.config.mjs +7 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/src/app/api/chat/route.ts +2445 -0
- package/template/src/app/api/connections/models/route.ts +255 -0
- package/template/src/app/api/connections/test/route.ts +124 -0
- package/template/src/app/api/local-sync/route.ts +74 -0
- package/template/src/app/api/tool-approval/route.ts +47 -0
- package/template/src/app/favicon.ico +0 -0
- package/template/src/app/globals.css +808 -0
- package/template/src/app/layout.tsx +74 -0
- package/template/src/app/page.tsx +444 -0
- package/template/src/components/ChatView.tsx +1537 -0
- package/template/src/components/Composer.tsx +160 -0
- package/template/src/components/MapView.tsx +244 -0
- package/template/src/components/MessageCard.tsx +955 -0
- package/template/src/components/SearchModal.tsx +72 -0
- package/template/src/components/SettingsModal.tsx +1257 -0
- package/template/src/components/Sidebar.tsx +153 -0
- package/template/src/components/TopBar.tsx +164 -0
- package/template/src/lib/connections.ts +275 -0
- package/template/src/lib/data.ts +324 -0
- package/template/src/lib/db.ts +49 -0
- package/template/src/lib/hooks.ts +76 -0
- package/template/src/lib/local-sync.ts +192 -0
- package/template/src/lib/memory.ts +695 -0
- package/template/src/lib/model-presets.ts +251 -0
- package/template/src/lib/store.ts +36 -0
- package/template/src/lib/tooling/approvals.ts +78 -0
- package/template/src/lib/tooling/providers/anthropic.ts +155 -0
- package/template/src/lib/tooling/providers/ollama.ts +73 -0
- package/template/src/lib/tooling/providers/openai.ts +267 -0
- package/template/src/lib/tooling/providers/openai_compatible.ts +16 -0
- package/template/src/lib/tooling/providers/types.ts +44 -0
- package/template/src/lib/tooling/registry.ts +103 -0
- package/template/src/lib/tooling/runtime.ts +189 -0
- package/template/src/lib/tooling/safety.ts +165 -0
- package/template/src/lib/tooling/tools/apps.ts +108 -0
- package/template/src/lib/tooling/tools/apps_plus.ts +153 -0
- package/template/src/lib/tooling/tools/communication.ts +883 -0
- package/template/src/lib/tooling/tools/files.ts +395 -0
- package/template/src/lib/tooling/tools/music.ts +988 -0
- package/template/src/lib/tooling/tools/notes.ts +461 -0
- package/template/src/lib/tooling/tools/notes_plus.ts +294 -0
- package/template/src/lib/tooling/tools/numbers.ts +175 -0
- package/template/src/lib/tooling/tools/schedule.ts +579 -0
- package/template/src/lib/tooling/tools/system.ts +142 -0
- package/template/src/lib/tooling/tools/web.ts +212 -0
- package/template/src/lib/tooling/tools/workflow.ts +218 -0
- package/template/src/lib/tooling/types.ts +27 -0
- package/template/src/lib/types.ts +309 -0
- package/template/src/lib/utils.ts +108 -0
- 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);
|