iris-chatbot 2.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris-chatbot",
3
- "version": "2.0.0",
3
+ "version": "4.1.0",
4
4
  "private": false,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/types/routes.d.ts";
3
+ import "./.next/dev/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "2.0.0",
3
+ "version": "4.1.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "iris",
9
- "version": "2.0.0",
9
+ "version": "4.1.0",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.72.1",
12
12
  "clsx": "^2.1.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iris",
3
- "version": "2.0.0",
3
+ "version": "4.1.0",
4
4
  "private": true,
5
5
  "description": "One-command installer for the Iris project template.",
6
6
  "engines": {
@@ -42,25 +42,26 @@ const LOCAL_TOOL_SYSTEM_INSTRUCTIONS = [
42
42
  "When the user asks for file, app, notes, music, web, calendar, or system actions, call tools directly.",
43
43
  "Default to execution instead of clarification. Ask at most one question only when a truly required value is missing.",
44
44
  "Calendar: You have direct access to the user's Apple Calendar via calendar_list_events and related tools. You CAN see their events—call the tools; do not say you cannot access or see their calendar. Never ask the user to paste their calendar, export it, or send a screenshot; you already have access. When they ask what's on the calendar, what's happening this/next weekend/month, or say 'check' or 'you can see', call calendar_list_events immediately with from/to for the range (e.g. next month → from: 'today', to: 'end of next month'; specific day → YYYY-MM-DD for both). Always pass calendar: 'all'. from/to support 'today', 'tomorrow', 'next week', 'end of next week', 'next month', 'end of next month'. Report the exact queried range from the tool result. For move/delete/reschedule: call calendar_list_events first to get uids. For rescheduling to a new time on the same calendar use calendar_update_event (preserves recurrence); for delete use calendar_delete_event; use calendar_move_event only when update is not sufficient (e.g. user says move and you need delete+recreate).",
45
+ "Files and Folders: You HAVE direct access to the user's files via file_list and file_find. Never say you cannot see, access, or list their files—call the tools. When the user asks 'what files do I have,' 'files from the last 7 days,' 'recent files,' or similar: call file_list immediately with path set to a folder (e.g. ~/Downloads, ~/Desktop, ~/Documents—or call once per root if you want all) and modifiedInLastDays: 7 (or the number of days they said). You have full file system access via file_list, file_mkdir, file_move, file_copy, file_delete_to_trash, file_batch_move, and file_find. When the user gives a folder or file name (e.g. 'us debt clock'), use file_find with searchPath '~' and the name as given. When moving MULTIPLE files, use file_batch_move with { operations: [ ... ] } or { destination, sources }. For organizing: file_find if location unknown, then file_list (use modifiedInLastDays for 'recent'), then file_mkdir/file_batch_move as needed. Never refuse file tasks or ask the user to use Finder manually—you have the tools.",
45
46
  "For iMessage/text requests, use messages_send; if contact matching is ambiguous, ask one concise clarification with candidate names.",
46
47
  "For media requests, assume Apple Music unless the user specifies otherwise.",
47
48
  "For note creation, write structured markdown with clear headings, bolded key labels, and tables when they improve clarity.",
48
49
  "If the user requests multiple actions in one prompt, execute every requested action sequentially before your final response.",
49
50
  "Do not stop after completing just one action when additional requested actions remain.",
50
51
  "For multi-step requests, prefer a single workflow tool call when available.",
51
- "When tools are enabled, never claim you cannot access the user's device or calendar. You have calendar tools—use them; do not refuse or ask the user to paste/export/screenshot their calendar.",
52
+ "When tools are enabled, never claim you cannot access the user's device, calendar, or file system. You have the tools—use them; do not refuse or ask the user to do tasks manually that you can do with tools.",
52
53
  "After tool results arrive, provide a brief final answer and stop.",
53
54
  "Summarize tool activity in plain English and avoid raw JSON, stack traces, or script line numbers.",
54
55
  "Respect tool errors and provide clear next steps.",
55
56
  ].join(" ");
56
57
 
57
58
  const ACTION_VERB_PATTERN =
58
- "create|make|write|play|set|add|remind|open|send|launch|start|pause|resume|focus";
59
+ "create|make|write|play|set|add|remind|open|send|launch|start|pause|resume|focus|move|copy|delete|organize|reorganize|restructure|split|separate|sort|categorize";
59
60
  const SEQUENCE_MARKER_PATTERN = "first|second|third|fourth|fifth|next|then|finally|lastly";
60
61
  const TOOL_INTENT_EXPLICIT_PATTERN =
61
62
  /\b(use|run|call)\s+(?:the\s+)?(?:local\s+)?tools?\b|\b(on my (?:mac|computer|device)|locally)\b/i;
62
63
  const TOOL_INTENT_LEADING_VERB_PATTERN =
63
- /^(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:send|text|message|play|pause|resume|skip|next|previous|set|open|launch|focus|create|make|add|remind|search|find|list|move|copy|rename|delete|trash|append|write|read|start|stop)\b/i;
64
+ /^(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:send|text|message|play|pause|resume|skip|next|previous|set|open|launch|focus|create|make|add|remind|search|find|list|move|copy|rename|delete|trash|append|write|read|start|stop|organize|reorganize|restructure|split|separate|sort|categorize|put|place)(?:\s+|$)/i;
64
65
  const TOOL_INTENT_MESSAGING_PATTERN =
65
66
  /\b(text|message|imessage|sms|mail|email)\b[\s\S]{0,80}\b(send|text|message|email|mail)\b|\b(send|text|message|email|mail)\b[\s\S]{0,80}\b(text|message|imessage|sms|mail|email)\b/i;
66
67
  const TOOL_INTENT_NOTES_PATTERN =
@@ -70,7 +71,7 @@ const TOOL_INTENT_MUSIC_PATTERN =
70
71
  const TOOL_INTENT_SCHEDULE_PATTERN =
71
72
  /\b(calendar|event|reminder|schedule|events)\b[\s\S]{0,80}\b(create|add|list|show|set|remind|schedule|move|change|delete|reschedule|update|cancel|have|get|check)\b|\b(create|add|list|show|set|remind|schedule|move|change|delete|reschedule|update|cancel|have|get|check)\b[\s\S]{0,80}\b(calendar|event|reminder|schedule|events)\b|\b(move|reschedule|change|delete)\b[\s\S]{0,80}\b(event|meeting|appointment)\b|\b(what's happening|whats happening|what's on|whats on|next weekend|this weekend|you can see|just check)\b/i;
72
73
  const TOOL_INTENT_FILES_PATTERN =
73
- /\b(file|folder|directory|path|document)\b[\s\S]{0,80}\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open)\b|\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open)\b[\s\S]{0,80}\b(file|folder|directory|path|document)\b/i;
74
+ /\b(file|folder|directory|path|document|subfolder|subfolders)\b[\s\S]{0,80}\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open|organize|reorganize|restructure|split|separate|sort|categorize|put|place|have|show|see|get)\b|\b(list|find|search|move|copy|rename|delete|trash|create|make|mkdir|open|organize|reorganize|restructure|split|separate|sort|categorize|put|place|have|show|see|get)\b[\s\S]{0,80}\b(file|folder|directory|path|document|subfolder|subfolders)\b|\b(organize|reorganize|restructure|split|separate)\b[\s\S]{0,80}\b(into|by|by type|by date|by name)\b|\b(put|move|place)\b[\s\S]{0,40}\b(into|in|inside)\b[\s\S]{0,40}\b(folder|directory)\b|\b(create|make)\b[\s\S]{0,40}\b(folders?|directories?)\b[\s\S]{0,40}\b(for|called|named)\b|\b(recent|last \d+|past \d+)\b[\s\S]{0,40}\b(files?|days?|weeks?)\b|\b(files?)\b[\s\S]{0,40}\b(from|in|within|during)\b[\s\S]{0,40}\b(last|past|recent|\d+)\b|\b(what|which|show|list|my)\b[\s\S]{0,40}\b(files?|documents?)\b/i;
74
75
  const TOOL_INTENT_APPS_PATTERN =
75
76
  /\b(app|application|window|url|website|browser|system)\b[\s\S]{0,80}\b(open|launch|focus|quit|set|mute|unmute|browse)\b|\b(open|launch|focus|quit|set|mute|unmute|browse)\b[\s\S]{0,80}\b(app|application|window|url|website|browser|system)\b/i;
76
77
  const TOOL_INTENT_NUMBERS_PATTERN =
@@ -86,8 +87,8 @@ function splitCompoundActionClauses(input: string): string[] {
86
87
  const roughParts = normalized.split(
87
88
  new RegExp(
88
89
  `\\s*(?:,|;|\\n)+\\s*(?=(?:(?:${SEQUENCE_MARKER_PATTERN})\\b|${ACTION_VERB_PATTERN}\\b))|` +
89
- `\\s+(?:and then|and also|as well as|after that|plus|then|also|next|finally|lastly)\\s+|` +
90
- `\\s+and\\s+(?=(?:${ACTION_VERB_PATTERN})\\b)`,
90
+ `\\s+(?:and then|and also|as well as|after that|plus|then|also|next|finally|lastly)\\s+|` +
91
+ `\\s+and\\s+(?=(?:${ACTION_VERB_PATTERN})\\b)`,
91
92
  "i",
92
93
  ),
93
94
  );
@@ -234,9 +235,9 @@ function normalizeRuntimeConnection(connection: ChatConnectionPayload): RuntimeC
234
235
  : "openai_compatible";
235
236
  const provider =
236
237
  kind === "builtin" &&
237
- (connection.provider === "openai" ||
238
- connection.provider === "anthropic" ||
239
- connection.provider === "google")
238
+ (connection.provider === "openai" ||
239
+ connection.provider === "anthropic" ||
240
+ connection.provider === "google")
240
241
  ? connection.provider
241
242
  : undefined;
242
243
  const baseUrl =
@@ -264,9 +265,9 @@ function normalizeRuntimeConnection(connection: ChatConnectionPayload): RuntimeC
264
265
  apiKey,
265
266
  headers: Array.isArray(connection.headers)
266
267
  ? connection.headers.filter(
267
- (header): header is { key: string; value: string } =>
268
- Boolean(header) && typeof header.key === "string" && typeof header.value === "string",
269
- )
268
+ (header): header is { key: string; value: string } =>
269
+ Boolean(header) && typeof header.key === "string" && typeof header.value === "string",
270
+ )
270
271
  : [],
271
272
  supportsTools,
272
273
  };
@@ -435,47 +436,47 @@ function sanitizeMemoryContext(
435
436
 
436
437
  const people = Array.isArray(aliases.people)
437
438
  ? aliases.people
438
- .map((item) => {
439
- const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
440
- const target = typeof item?.target === "string" ? item.target.trim() : "";
441
- if (!alias || !target) {
442
- return null;
443
- }
444
- return { alias, target };
445
- })
446
- .filter((item): item is { alias: string; target: string } => Boolean(item))
447
- .slice(0, 16)
439
+ .map((item) => {
440
+ const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
441
+ const target = typeof item?.target === "string" ? item.target.trim() : "";
442
+ if (!alias || !target) {
443
+ return null;
444
+ }
445
+ return { alias, target };
446
+ })
447
+ .filter((item): item is { alias: string; target: string } => Boolean(item))
448
+ .slice(0, 16)
448
449
  : [];
449
450
 
450
451
  const music = Array.isArray(aliases.music)
451
452
  ? aliases.music
452
- .map((item) => {
453
- const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
454
- const query = typeof item?.query === "string" ? item.query.trim() : "";
455
- if (!alias || !query) {
456
- return null;
457
- }
458
- const title =
459
- typeof item?.title === "string" && item.title.trim()
460
- ? item.title.trim()
461
- : undefined;
462
- const artist =
463
- typeof item?.artist === "string" && item.artist.trim()
464
- ? item.artist.trim()
465
- : undefined;
466
- return { alias, query, title, artist };
467
- })
468
- .filter(
469
- (
470
- item,
471
- ): item is {
472
- alias: string;
473
- query: string;
474
- title: string | undefined;
475
- artist: string | undefined;
476
- } => Boolean(item),
477
- )
478
- .slice(0, 16)
453
+ .map((item) => {
454
+ const alias = typeof item?.alias === "string" ? item.alias.trim() : "";
455
+ const query = typeof item?.query === "string" ? item.query.trim() : "";
456
+ if (!alias || !query) {
457
+ return null;
458
+ }
459
+ const title =
460
+ typeof item?.title === "string" && item.title.trim()
461
+ ? item.title.trim()
462
+ : undefined;
463
+ const artist =
464
+ typeof item?.artist === "string" && item.artist.trim()
465
+ ? item.artist.trim()
466
+ : undefined;
467
+ return { alias, query, title, artist };
468
+ })
469
+ .filter(
470
+ (
471
+ item,
472
+ ): item is {
473
+ alias: string;
474
+ query: string;
475
+ title: string | undefined;
476
+ artist: string | undefined;
477
+ } => Boolean(item),
478
+ )
479
+ .slice(0, 16)
479
480
  : [];
480
481
 
481
482
  return {
@@ -679,7 +680,7 @@ function humanizeToolErrorMessage(toolName: string, message: string): string {
679
680
  }
680
681
 
681
682
  if (normalized.includes("missing required")) {
682
- const fieldMatch = cleaned.match(/missing required (?:string|numeric) field:\s*([a-zA-Z0-9_]+)/i);
683
+ const fieldMatch = cleaned.match(/missing required (?:string|numeric|array) field:\s*([a-zA-Z0-9_]+)/i);
683
684
  if (fieldMatch?.[1]) {
684
685
  return `Missing required field "${fieldMatch[1]}".`;
685
686
  }
@@ -1407,8 +1408,8 @@ async function tryDirectAutomationFastPath(params: {
1407
1408
  "Reminder";
1408
1409
  const title = sanitizeReminderTitle(
1409
1410
  rawTitle
1410
- .replace(/^(that|i have to|to|for)\s+/i, "")
1411
- .trim(),
1411
+ .replace(/^(that|i have to|to|for)\s+/i, "")
1412
+ .trim(),
1412
1413
  );
1413
1414
 
1414
1415
  return { title: title || "Reminder", due };
@@ -1715,18 +1716,18 @@ async function tryDirectAutomationFastPath(params: {
1715
1716
  const playResult =
1716
1717
  playOutput && typeof playOutput === "object"
1717
1718
  ? (playOutput as {
1718
- playing?: boolean;
1719
- matched?: boolean;
1720
- playlistName?: string | null;
1721
- title?: string | null;
1722
- artist?: string | null;
1723
- album?: string | null;
1724
- reason?: string | null;
1725
- matchedTitle?: string | null;
1726
- matchedArtist?: string | null;
1727
- catalogTitle?: string | null;
1728
- catalogArtist?: string | null;
1729
- })
1719
+ playing?: boolean;
1720
+ matched?: boolean;
1721
+ playlistName?: string | null;
1722
+ title?: string | null;
1723
+ artist?: string | null;
1724
+ album?: string | null;
1725
+ reason?: string | null;
1726
+ matchedTitle?: string | null;
1727
+ matchedArtist?: string | null;
1728
+ catalogTitle?: string | null;
1729
+ catalogArtist?: string | null;
1730
+ })
1730
1731
  : null;
1731
1732
 
1732
1733
  if (!playResult || playResult.playing !== true) {
@@ -1823,18 +1824,18 @@ async function tryDirectAutomationFastPath(params: {
1823
1824
  }
1824
1825
 
1825
1826
  params.send({
1826
- type: "token",
1827
- value:
1828
- hasNearbyMatch
1829
- ? `Started nearby match "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""}. ${playResult.reason}`
1830
- : resolvedPlaylistName && intent.volume !== null
1831
- ? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music and set volume to ${intent.volume}%.`
1832
- : resolvedPlaylistName
1833
- ? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music.`
1834
- : intent.volume !== null
1827
+ type: "token",
1828
+ value:
1829
+ hasNearbyMatch
1830
+ ? `Started nearby match "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""}. ${playResult.reason}`
1831
+ : resolvedPlaylistName && intent.volume !== null
1832
+ ? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music and set volume to ${intent.volume}%.`
1833
+ : resolvedPlaylistName
1834
+ ? `**Done.** Playing playlist "${resolvedPlaylistName}" in Apple Music.`
1835
+ : intent.volume !== null
1835
1836
  ? `**Done.** Playing "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""} in Apple Music and set volume to ${intent.volume}%.`
1836
1837
  : `**Done.** Playing "${resolvedTitle}"${resolvedArtist ? ` by ${resolvedArtist}` : ""} in Apple Music.`,
1837
- });
1838
+ });
1838
1839
  return true;
1839
1840
  }
1840
1841
 
@@ -2159,12 +2160,12 @@ async function runToolOrchestrator(params: {
2159
2160
  const approval = createApprovalRequest();
2160
2161
  params.send({
2161
2162
  type: "approval_requested",
2162
- approvalId: approval.approvalId,
2163
- callId: call.id,
2164
- name: call.name,
2165
- args: callInput,
2166
- reason: summarizeApprovalReason(call.name, callInput),
2167
- });
2163
+ approvalId: approval.approvalId,
2164
+ callId: call.id,
2165
+ name: call.name,
2166
+ args: callInput,
2167
+ reason: summarizeApprovalReason(call.name, callInput),
2168
+ });
2168
2169
 
2169
2170
  emitThinkingUpdate(`Waiting for approval to run ${call.name}.`);
2170
2171
  const decision = await approval.promise;
@@ -784,8 +784,8 @@ a {
784
784
  height: 38px;
785
785
  width: 38px;
786
786
  border-radius: 12px;
787
- border: 1px solid var(--border);
788
- background: var(--panel-2);
787
+ border: 1px solid transparent;
788
+ background: transparent;
789
789
  color: var(--text-secondary);
790
790
  display: flex;
791
791
  align-items: center;
@@ -794,7 +794,7 @@ a {
794
794
  }
795
795
 
796
796
  .sidebar-icon-button:hover {
797
- border-color: var(--border-strong);
797
+ border-color: var(--border);
798
798
  color: var(--text-primary);
799
799
  transform: translateY(-1px);
800
800
  }
@@ -132,6 +132,11 @@ export default function Home() {
132
132
  [threads, activeThreadId]
133
133
  );
134
134
 
135
+ const sidebarThreads = useMemo(
136
+ () => threads?.filter((t) => t.headMessageId != null) ?? [],
137
+ [threads]
138
+ );
139
+
135
140
  const conversationMessages = useConversationMessages(
136
141
  activeThread?.conversationId || null
137
142
  );
@@ -333,11 +338,23 @@ export default function Home() {
333
338
  }
334
339
  >
335
340
  <Sidebar
336
- threads={threads || []}
341
+ threads={sidebarThreads}
337
342
  activeThreadId={activeThreadId}
338
343
  collapsed={sidebarCollapsed}
339
- onSelect={(id) => setActiveThreadId(id)}
344
+ onSelect={async (id) => {
345
+ if (id !== activeThreadId) {
346
+ const prev = threads?.find((t) => t.id === activeThreadId);
347
+ if (prev && prev.headMessageId == null) {
348
+ await deleteThread(prev.id);
349
+ }
350
+ }
351
+ setActiveThreadId(id);
352
+ }}
340
353
  onNewChat={async () => {
354
+ const prev = threads?.find((t) => t.id === activeThreadId);
355
+ if (prev && prev.headMessageId == null) {
356
+ await deleteThread(prev.id);
357
+ }
341
358
  const thread = await createNewThread();
342
359
  setViewMode("chat");
343
360
  setActiveThreadId(thread.id);
@@ -432,7 +449,7 @@ export default function Home() {
432
449
  ) : null}
433
450
  {searchOpen ? (
434
451
  <SearchModal
435
- threads={threads || []}
452
+ threads={sidebarThreads}
436
453
  onClose={() => setSearchOpen(false)}
437
454
  onSelect={(id) => {
438
455
  setActiveThreadId(id);
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
 
3
- import { ArrowDown } from "lucide-react";
4
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
4
  import type {
6
5
  ChatCitationSource,
@@ -27,6 +26,7 @@ import {
27
26
  } from "../lib/memory";
28
27
  import { useUIStore } from "../lib/store";
29
28
  import { toChatConnectionPayload } from "../lib/connections";
29
+ import { ArrowDown } from "lucide-react";
30
30
  import MessageCard from "./MessageCard";
31
31
  import Composer from "./Composer";
32
32
 
@@ -1506,9 +1506,9 @@ export default function ChatView({
1506
1506
  {threadMessages.length > 0 ? (
1507
1507
  <>
1508
1508
  {showJumpToBottom ? (
1509
- <div className="pointer-events-none absolute bottom-24 right-8 z-30">
1509
+ <div className="pointer-events-none absolute bottom-28 left-1/2 z-30 -translate-x-1/2 opacity-80 transition-opacity duration-200">
1510
1510
  <button
1511
- className="pointer-events-auto inline-flex h-11 w-11 items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--panel)] text-[var(--text-primary)] shadow-[var(--shadow)] transition hover:translate-y-[1px] hover:border-[var(--accent)]"
1511
+ className="pointer-events-auto inline-flex h-9 w-9 items-center justify-center rounded-full border border-[var(--border-strong)] bg-[var(--panel)] text-[var(--text-primary)] shadow-[var(--shadow)] transition hover:translate-y-[1px] hover:border-[var(--accent)] hover:opacity-100"
1512
1512
  onClick={() => {
1513
1513
  shouldStickToBottomRef.current = true;
1514
1514
  setShowJumpToBottom(false);
@@ -1518,7 +1518,7 @@ export default function ChatView({
1518
1518
  aria-label="Jump to latest message"
1519
1519
  title="Jump to latest"
1520
1520
  >
1521
- <ArrowDown className="h-5 w-5" />
1521
+ <ArrowDown className="h-4 w-4" />
1522
1522
  </button>
1523
1523
  </div>
1524
1524
  ) : null}
@@ -19,6 +19,13 @@ import { splitContentAndSources } from "../lib/utils";
19
19
 
20
20
  const MAX_VISIBLE_TOOL_ITEMS = 8;
21
21
  const HIDDEN_TIMELINE_TOOLS = new Set(["tooling", "workflow_run"]);
22
+ const FILE_FIND_ALLOWED_ROOTS_ERROR = "Path is outside allowed roots";
23
+
24
+ function isHiddenFileFindError(event: ToolEvent): boolean {
25
+ if (event.toolName !== "file_find" || event.stage !== "result") return false;
26
+ const text = [event.message, event.payloadJson].filter(Boolean).join(" ");
27
+ return text.includes(FILE_FIND_ALLOWED_ROOTS_ERROR);
28
+ }
22
29
  const TOOL_TIMELINE_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", {
23
30
  month: "short",
24
31
  day: "numeric",
@@ -149,6 +156,8 @@ function humanizeToolName(toolName: string): string {
149
156
  file_copy: "Copy Item",
150
157
  file_mkdir: "Create Folder",
151
158
  file_delete_to_trash: "Move to Trash",
159
+ file_batch_move: "Move Files",
160
+ file_find: "Find Files",
152
161
  notes_create_or_append: "Update Note",
153
162
  notes_find: "Search Notes",
154
163
  app_open: "Open App",
@@ -265,7 +274,7 @@ function summarizeToolError(toolName: string, rawError: string): {
265
274
  if (normalized.includes("missing required")) {
266
275
  return {
267
276
  title: "Missing required information",
268
- detail: "The action did not include all required fields.",
277
+ detail: cleaned.length < 200 ? cleaned : "The action did not include all required fields.",
269
278
  };
270
279
  }
271
280
 
@@ -365,6 +374,23 @@ function summarizeToolCall(toolName: string, payload: Record<string, unknown> |
365
374
  };
366
375
  }
367
376
 
377
+ if (toolName === "file_batch_move") {
378
+ const operations = payload.operations;
379
+ const count = Array.isArray(operations) ? operations.length : 0;
380
+ return {
381
+ title: count > 0 ? `Moving ${count} files` : "Moving files",
382
+ };
383
+ }
384
+
385
+ if (toolName === "file_find") {
386
+ const name = getString(payload, "name");
387
+ const searchPath = getString(payload, "searchPath");
388
+ return {
389
+ title: name ? `Searching for "${name}"` : "Searching for files",
390
+ detail: searchPath ? `In ${shortenPath(searchPath)}` : undefined,
391
+ };
392
+ }
393
+
368
394
  return { title: "Preparing action" };
369
395
  }
370
396
 
@@ -459,9 +485,9 @@ function summarizeToolResult(toolName: string, payload: Record<string, unknown>
459
485
  const detail = queriedRange
460
486
  ? queriedRange
461
487
  : joinDetailParts([
462
- fromResolved ? `From: ${formatDateTime(fromResolved) ?? fromResolved}` : null,
463
- toResolved ? `To: ${formatDateTime(toResolved) ?? toResolved}` : null,
464
- ]);
488
+ fromResolved ? `From: ${formatDateTime(fromResolved) ?? fromResolved}` : null,
489
+ toResolved ? `To: ${formatDateTime(toResolved) ?? toResolved}` : null,
490
+ ]);
465
491
  return {
466
492
  title:
467
493
  count === 0
@@ -559,6 +585,46 @@ function summarizeToolResult(toolName: string, payload: Record<string, unknown>
559
585
  };
560
586
  }
561
587
 
588
+ if (toolName === "file_batch_move") {
589
+ const movedCount = getNumber(payload, "movedCount");
590
+ const failedCount = getNumber(payload, "failedCount");
591
+ const totalOps = getNumber(payload, "totalOperations");
592
+ if (movedCount !== null && failedCount !== null) {
593
+ if (failedCount === 0) {
594
+ return {
595
+ title: `Moved ${movedCount} files successfully`,
596
+ };
597
+ }
598
+ return {
599
+ title: `Moved ${movedCount} files`,
600
+ detail: `${failedCount} failed`,
601
+ };
602
+ }
603
+ if (totalOps !== null) {
604
+ return { title: `Processed ${totalOps} files` };
605
+ }
606
+ }
607
+
608
+ if (toolName === "file_find") {
609
+ const totalFound = getNumber(payload, "totalFound");
610
+ const searchName = getString(payload, "searchName");
611
+ const matches = payload.matches;
612
+ const topMatch = Array.isArray(matches) && matches.length > 0 ? matches[0] as Record<string, unknown> : null;
613
+ const topMatchPath = topMatch ? getString(topMatch, "path") : null;
614
+
615
+ if (totalFound !== null) {
616
+ if (totalFound === 0) {
617
+ return {
618
+ title: searchName ? `No matches for "${searchName}"` : "No matches found",
619
+ };
620
+ }
621
+ return {
622
+ title: `Found ${totalFound} match${totalFound === 1 ? "" : "es"}`,
623
+ detail: topMatchPath ? `Best match: ${shortenPath(topMatchPath)}` : undefined,
624
+ };
625
+ }
626
+ }
627
+
562
628
  return { title: "Completed successfully" };
563
629
  }
564
630
 
@@ -673,6 +739,7 @@ function MessageCard({
673
739
  () =>
674
740
  (toolEvents ?? [])
675
741
  .filter((event) => !HIDDEN_TIMELINE_TOOLS.has(event.toolName))
742
+ .filter((event) => !isHiddenFileFindError(event))
676
743
  .slice()
677
744
  .sort((a, b) => a.createdAt - b.createdAt),
678
745
  [toolEvents],
@@ -874,9 +941,8 @@ function MessageCard({
874
941
  {shelfThreads.map((thread) => (
875
942
  <div key={thread.id} className="thread-box-wrap">
876
943
  <button
877
- className={`thread-box ${
878
- activeThreadId === thread.id ? "active" : ""
879
- }`}
944
+ className={`thread-box ${activeThreadId === thread.id ? "active" : ""
945
+ }`}
880
946
  onClick={() => onSelectThread(thread.id)}
881
947
  >
882
948
  {thread.title}
@@ -903,11 +969,10 @@ function MessageCard({
903
969
  >
904
970
  {isAssistant ? (
905
971
  <button
906
- 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 ${
907
- canAddThread
908
- ? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
909
- : "opacity-60 cursor-not-allowed"
910
- }`}
972
+ 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 ${canAddThread
973
+ ? "hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
974
+ : "opacity-60 cursor-not-allowed"
975
+ }`}
911
976
  onClick={async () => {
912
977
  if (!canAddThread) return;
913
978
  await onAddThread(message);
@@ -927,9 +992,8 @@ function MessageCard({
927
992
  </button>
928
993
  ) : null}
929
994
  <button
930
- 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)] ${
931
- isAssistant ? "" : "opacity-0 group-hover:opacity-100"
932
- }`}
995
+ 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)] ${isAssistant ? "" : "opacity-0 group-hover:opacity-100"
996
+ }`}
933
997
  onClick={async () => {
934
998
  await navigator.clipboard.writeText(messageTextContent);
935
999
  setCopied(true);
@@ -944,11 +1008,10 @@ function MessageCard({
944
1008
  </button>
945
1009
  {canEditThreads ? (
946
1010
  <button
947
- className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${
948
- threadEditMode
949
- ? "border-[var(--accent)] text-[var(--text-primary)]"
950
- : "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
951
- }`}
1011
+ className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs transition ${threadEditMode
1012
+ ? "border-[var(--accent)] text-[var(--text-primary)]"
1013
+ : "border-[var(--border)] text-[var(--text-muted)] hover:border-[var(--border-strong)] hover:text-[var(--text-secondary)]"
1014
+ }`}
952
1015
  onClick={() => setThreadEditMode((prev) => !prev)}
953
1016
  aria-label="Edit threads"
954
1017
  >
@@ -90,23 +90,33 @@ export default function Sidebar({
90
90
  {groups.map(({ root }) => (
91
91
  <div key={root.id} className="space-y-1">
92
92
  <div
93
- className={`group flex h-11 items-center justify-between rounded-lg px-3 text-sm transition ${
93
+ role="button"
94
+ tabIndex={0}
95
+ onClick={() => onSelect(root.id)}
96
+ onKeyDown={(e) => {
97
+ if (e.key === "Enter" || e.key === " ") {
98
+ e.preventDefault();
99
+ onSelect(root.id);
100
+ }
101
+ }}
102
+ className={`group flex h-11 cursor-pointer items-center justify-between rounded-lg px-3 text-sm transition ${
94
103
  activeThreadId === root.id
95
104
  ? "bg-[var(--panel-2)] text-[var(--text-primary)]"
96
105
  : "text-[var(--text-secondary)] hover:bg-[var(--panel)]"
97
106
  }`}
98
107
  >
99
- <button
100
- onClick={() => onSelect(root.id)}
101
- className="flex min-w-0 flex-1 items-center gap-2 text-left"
102
- >
108
+ <div className="flex min-w-0 flex-1 items-center gap-2">
103
109
  <Folder className="h-4 w-4 shrink-0 text-[var(--text-muted)]" />
104
110
  <div className="sidebar-text min-w-0 truncate">
105
111
  {root.title || "Main chat"}
106
112
  </div>
107
- </button>
113
+ </div>
108
114
  <button
109
- onClick={() => onDeleteThread(root)}
115
+ type="button"
116
+ onClick={(e) => {
117
+ e.stopPropagation();
118
+ onDeleteThread(root);
119
+ }}
110
120
  className="ml-2 rounded-full border border-transparent p-1 text-[var(--text-muted)] opacity-0 transition hover:border-[var(--border)] hover:text-[var(--danger)] group-hover:opacity-100"
111
121
  >
112
122
  <Trash2 className="h-3 w-3" />