From 539453fd614d4c6519abf6dde1853b37e17c6993 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 18:48:32 +0000 Subject: [PATCH 1/2] fix(chat): transform tool output data to match renderer expectations - list_drives: map 'title' field to 'name' for DriveListRenderer - list_pages: parse flat 'paths' strings into tree structure for PageTreeRenderer - get_activity: flatten grouped 'drives[].activities' format into flat 'activities' array for ActivityRenderer https://claude.ai/code/session_01AKG5EjddKT1CVdkLYfnvX9 --- .../chat/tool-calls/ToolCallRenderer.tsx | 209 ++++++++++++++++-- 1 file changed, 193 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx index 630c72740..78049cc4d 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx @@ -46,6 +46,82 @@ const safeJsonParse = (value: unknown): Record | null => { return null; }; +// Helper to parse list_pages paths format into tree structure +// Path format: "📁 [FOLDER](Task) ID: xxx Path: /drive/folder" +const parsePathsToTree = (paths: string[], driveId?: string): TreeItem[] => { + const pathRegex = /^\S+\s+\[([^\]]+)\](?:\s+\(Task\))?\s+ID:\s+(\S+)\s+Path:\s+(.+)$/; + + interface ParsedPage { + type: string; + pageId: string; + fullPath: string; + title: string; + pathSegments: string[]; + } + + const parsedPages: ParsedPage[] = []; + + for (const path of paths) { + const match = path.match(pathRegex); + if (match) { + const [, type, pageId, fullPath] = match; + const segments = fullPath.split('/').filter(Boolean); + const title = segments[segments.length - 1] || 'Untitled'; + parsedPages.push({ + type, + pageId, + fullPath, + title, + pathSegments: segments, + }); + } + } + + // Build tree from parsed pages + const buildTreeFromParsed = (pages: ParsedPage[], depth: number, parentPath: string[]): TreeItem[] => { + const result: TreeItem[] = []; + const seen = new Map(); + + for (const page of pages) { + if (page.pathSegments.length <= depth) continue; + + const currentSegment = page.pathSegments[depth]; + const isDirectChild = page.pathSegments.length === depth + 1; + + if (!seen.has(currentSegment)) { + seen.set(currentSegment, { + page: isDirectChild ? page : page, + children: [] + }); + } + + const entry = seen.get(currentSegment)!; + if (isDirectChild) { + entry.page = page; + } else { + entry.children.push(page); + } + } + + for (const [segment, { page, children }] of seen) { + const currentPath = [...parentPath, segment]; + const item: TreeItem = { + path: '/' + currentPath.join('/'), + title: segment, + type: page.type, + pageId: page.pageId, + children: buildTreeFromParsed(children, depth + 1, currentPath), + }; + result.push(item); + } + + return result; + }; + + // Start building from depth 1 (skip drive slug at depth 0) + return buildTreeFromParsed(parsedPages, 1, []); +}; + // Tool name mapping const TOOL_NAME_MAP: Record = { // Drive tools @@ -162,7 +238,16 @@ const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = // === DRIVE TOOLS === if (toolName === 'list_drives' && parsedOutput.drives) { - return ; + // Transform drive data: tool returns 'title' but renderer expects 'name' + const drives = (parsedOutput.drives as Array<{ id: string; slug: string; title?: string; name?: string; description?: string; isPersonal?: boolean; memberCount?: number }>).map(d => ({ + id: d.id, + name: d.name || d.title || 'Untitled', // Prefer name, fall back to title + slug: d.slug, + description: d.description, + isPersonal: d.isPersonal, + memberCount: d.memberCount, + })); + return ; } if (toolName === 'create_drive' || toolName === 'rename_drive') { @@ -191,14 +276,31 @@ const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = } // === PAGE READ TOOLS === - if (toolName === 'list_pages' && parsedOutput.tree) { - return ( - - ); + // Handle both tree format (newer) and paths format (current) + if (toolName === 'list_pages') { + if (parsedOutput.tree) { + return ( + + ); + } + // Convert paths array format to tree + if (parsedOutput.paths && Array.isArray(parsedOutput.paths)) { + const tree = parsePathsToTree( + parsedOutput.paths as string[], + parsedOutput.driveId as string | undefined + ); + return ( + + ); + } } if (toolName === 'list_trash' && parsedOutput.tree) { @@ -439,13 +541,88 @@ const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = } // === ACTIVITY === - if (toolName === 'get_activity' && parsedOutput.activities) { - return ( - - ); + // Handle both flat 'activities' format and grouped 'drives' format + if (toolName === 'get_activity') { + // Direct activities array format + if (parsedOutput.activities) { + return ( + + ); + } + // Grouped drives format from activity-tools.ts + if (parsedOutput.drives && Array.isArray(parsedOutput.drives)) { + const actors = (parsedOutput.actors || []) as Array<{ email: string; name: string | null; isYou: boolean; count: number }>; + const driveGroups = parsedOutput.drives as Array<{ + drive: { id: string; name: string; slug: string; context: string | null }; + activities: Array<{ + ts: string; + op: string; + res: string; + title: string | null; + pageId: string | null; + actor: number; + ai?: string; + fields?: string[]; + delta?: Record; + }>; + stats: { total: number; byOp: Record; aiCount: number }; + }>; + + // Map operation names to action types + const opToAction = (op: string): 'created' | 'updated' | 'deleted' | 'restored' | 'moved' | 'commented' | 'renamed' => { + switch (op) { + case 'create': return 'created'; + case 'update': return 'updated'; + case 'delete': + case 'trash': return 'deleted'; + case 'restore': return 'restored'; + case 'move': + case 'reorder': return 'moved'; + case 'rename': return 'renamed'; + default: return 'updated'; + } + }; + + // Flatten grouped activities + const flatActivities: ActivityItem[] = []; + for (const group of driveGroups) { + for (const activity of group.activities) { + const actor = actors[activity.actor]; + flatActivities.push({ + id: `${group.drive.id}-${activity.ts}-${activity.pageId || 'no-page'}`, + action: opToAction(activity.op), + pageId: activity.pageId || undefined, + pageTitle: activity.title || undefined, + pageType: activity.res === 'page' ? undefined : activity.res, + driveId: group.drive.id, + driveName: group.drive.name, + actorName: actor?.name || actor?.email || undefined, + timestamp: activity.ts, + summary: activity.ai ? `AI-generated (${activity.ai})` : undefined, + }); + } + } + + // Sort by timestamp descending + flatActivities.sort((a, b) => { + const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return timeB - timeA; + }); + + const meta = parsedOutput.meta as { window?: string } | undefined; + const period = meta?.window ? `Last ${meta.window}` : undefined; + + return ( + + ); + } } // === TASK TOOLS === From c7d3c31c879a9a1d5c264a9e804cd0ae9d9ef073 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Mon, 2 Feb 2026 13:07:38 -0600 Subject: [PATCH 2/2] fix(chat): address code review feedback for tool renderers - Fix synthetic folder nodes inheriting descendant IDs/types by making page optional in buildTreeFromParsed and defaulting to 'FOLDER' type - Fix duplicate titles collapsing by using composite key (pageId+segment) - Pass CUID2 activity IDs from tool output instead of synthesizing them - Prefer driveName over driveSlug for friendly display names - Remove unused DriveInfo import to fix lint error Co-Authored-By: Claude Opus 4.5 --- .../chat/tool-calls/ToolCallRenderer.tsx | 32 +++++++++++-------- apps/web/src/lib/ai/tools/activity-tools.ts | 2 ++ 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx index 78049cc4d..c7354d8cf 100644 --- a/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx +++ b/apps/web/src/components/ai/shared/chat/tool-calls/ToolCallRenderer.tsx @@ -10,7 +10,7 @@ import { TaskRenderer } from './TaskRenderer'; import { RichContentRenderer } from './RichContentRenderer'; import { RichDiffRenderer } from './RichDiffRenderer'; import { PageTreeRenderer, type TreeItem } from './PageTreeRenderer'; -import { DriveListRenderer, type DriveInfo } from './DriveListRenderer'; +import { DriveListRenderer } from './DriveListRenderer'; import { ActionResultRenderer } from './ActionResultRenderer'; import { SearchResultsRenderer, type SearchResult } from './SearchResultsRenderer'; import { AgentListRenderer, type AgentInfo } from './AgentListRenderer'; @@ -48,7 +48,7 @@ const safeJsonParse = (value: unknown): Record | null => { // Helper to parse list_pages paths format into tree structure // Path format: "📁 [FOLDER](Task) ID: xxx Path: /drive/folder" -const parsePathsToTree = (paths: string[], driveId?: string): TreeItem[] => { +const parsePathsToTree = (paths: string[], _driveId?: string): TreeItem[] => { const pathRegex = /^\S+\s+\[([^\]]+)\](?:\s+\(Task\))?\s+ID:\s+(\S+)\s+Path:\s+(.+)$/; interface ParsedPage { @@ -78,24 +78,27 @@ const parsePathsToTree = (paths: string[], driveId?: string): TreeItem[] => { } // Build tree from parsed pages + // Use composite key (pageId + segment) to prevent duplicate titles from collapsing const buildTreeFromParsed = (pages: ParsedPage[], depth: number, parentPath: string[]): TreeItem[] => { const result: TreeItem[] = []; - const seen = new Map(); + const seen = new Map(); for (const page of pages) { if (page.pathSegments.length <= depth) continue; const currentSegment = page.pathSegments[depth]; const isDirectChild = page.pathSegments.length === depth + 1; + // Use composite key to prevent pages with same title from collapsing + const mapKey = isDirectChild ? `${currentSegment}:${page.pageId}` : currentSegment; - if (!seen.has(currentSegment)) { - seen.set(currentSegment, { - page: isDirectChild ? page : page, + if (!seen.has(mapKey)) { + seen.set(mapKey, { + page: isDirectChild ? page : undefined, children: [] }); } - const entry = seen.get(currentSegment)!; + const entry = seen.get(mapKey)!; if (isDirectChild) { entry.page = page; } else { @@ -103,13 +106,13 @@ const parsePathsToTree = (paths: string[], driveId?: string): TreeItem[] => { } } - for (const [segment, { page, children }] of seen) { - const currentPath = [...parentPath, segment]; + for (const [, { page, children }] of seen) { + const currentPath = [...parentPath, page?.title || children[0]?.pathSegments[depth] || 'unknown']; const item: TreeItem = { path: '/' + currentPath.join('/'), - title: segment, - type: page.type, - pageId: page.pageId, + title: page?.title || children[0]?.pathSegments[depth] || 'Folder', + type: page?.type ?? 'FOLDER', + pageId: page?.pageId, children: buildTreeFromParsed(children, depth + 1, currentPath), }; result.push(item); @@ -296,7 +299,7 @@ const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = return ( ); @@ -558,6 +561,7 @@ const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = const driveGroups = parsedOutput.drives as Array<{ drive: { id: string; name: string; slug: string; context: string | null }; activities: Array<{ + id: string; ts: string; op: string; res: string; @@ -592,7 +596,7 @@ const ToolCallRendererInternal: React.FC<{ part: ToolPart; toolName: string }> = for (const activity of group.activities) { const actor = actors[activity.actor]; flatActivities.push({ - id: `${group.drive.id}-${activity.ts}-${activity.pageId || 'no-page'}`, + id: activity.id, action: opToAction(activity.op), pageId: activity.pageId || undefined, pageTitle: activity.title || undefined, diff --git a/apps/web/src/lib/ai/tools/activity-tools.ts b/apps/web/src/lib/ai/tools/activity-tools.ts index fe2acf920..cad9b8a79 100644 --- a/apps/web/src/lib/ai/tools/activity-tools.ts +++ b/apps/web/src/lib/ai/tools/activity-tools.ts @@ -102,6 +102,7 @@ async function getLastVisitTime(userId: string): Promise { // Compact activity format optimized for AI context efficiency interface CompactActivity { + id: string; // CUID2 activity ID from database ts: string; // ISO timestamp op: string; // operation res: string; // resourceType @@ -512,6 +513,7 @@ When summarizing multiple changes, group them thematically and describe the over // Build compact activity const actorIdx = actorMap.get(activity.actorEmail)!.idx; const compact: CompactActivity = { + id: activity.id, ts: activity.timestamp.toISOString(), op: activity.operation, res: activity.resourceType,