From 4ab307b10f0db75d4327ff12da6e7cb7ccf6c3a4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 19 Mar 2026 13:04:44 +0700 Subject: [PATCH] Add fetch tool details and improve sidebar shortcuts handling --- packages/app/src/components/left-sidebar.tsx | 119 +++++--- .../src/components/sidebar-workspace-list.tsx | 267 +++++++++++++----- .../app/src/components/tool-call-details.tsx | 74 ++++- .../src/hooks/use-sidebar-shortcut-model.ts | 22 +- .../utils/sidebar-project-row-model.test.ts | 66 ++++- .../src/utils/sidebar-project-row-model.ts | 43 +-- .../app/src/utils/sidebar-shortcuts.test.ts | 14 +- packages/app/src/utils/sidebar-shortcuts.ts | 3 +- .../src/utils/tauri-daemon-transport.test.ts | 84 ++++++ .../app/src/utils/tauri-daemon-transport.ts | 51 ++-- .../src/utils/tool-call-detail-state.test.ts | 22 ++ .../app/src/utils/tool-call-detail-state.ts | 10 +- packages/app/src/utils/tool-call-icon.ts | 1 + packages/desktop/src-tauri/Cargo.lock | 2 +- .../src/server/agent/agent-sdk-types.ts | 24 ++ .../claude/tool-call-detail-parser.ts | 37 ++- .../providers/claude/tool-call-mapper.test.ts | 97 ++++++- .../providers/claude/tool-call-mapper.ts | 23 +- .../agent/providers/codex-app-server-agent.ts | 6 +- .../codex/tool-call-detail-parser.ts | 4 +- .../providers/codex/tool-call-mapper.test.ts | 2 + .../opencode/tool-call-detail-parser.ts | 4 +- .../opencode/tool-call-mapper.test.ts | 2 + .../providers/tool-call-detail-primitives.ts | 149 +++++++++- packages/server/src/shared/messages.ts | 26 ++ .../server/src/shared/tool-call-display.ts | 5 + 26 files changed, 960 insertions(+), 197 deletions(-) diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index c91ac29fd..5d131df52 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -11,6 +11,8 @@ import Animated, { import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { MessagesSquare, Plus, Settings } from 'lucide-react-native' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Shortcut } from '@/components/ui/shortcut' import { router, usePathname } from 'expo-router' import { usePanelStore } from '@/stores/panel-store' import { SidebarWorkspaceList } from './sidebar-workspace-list' @@ -117,7 +119,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr serverId: activeServerId, enabled: isOpen, }) - const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = + const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } = useSidebarShortcutModel(projects) const { translateX, @@ -312,12 +314,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr {({ hovered }) => ( <> - @@ -327,7 +329,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr hovered && styles.newAgentButtonTextHovered, ]} > - Add project + Sessions )} @@ -343,6 +345,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr serverId={activeServerId} collapsedProjectKeys={collapsedProjectKeys} onToggleProjectCollapsed={toggleProjectCollapsed} + onSetProjectCollapsed={setProjectCollapsed} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} projects={projects} isRefreshing={isManualRefresh && isRevalidating} @@ -373,23 +376,33 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr - - {({ hovered }) => ( - - )} - + + + + {({ hovered }) => ( + + )} + + + + + Add project + + + + {({ hovered }) => ( <> - - Add project + Sessions )} @@ -469,6 +482,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr serverId={activeServerId} collapsedProjectKeys={collapsedProjectKeys} onToggleProjectCollapsed={toggleProjectCollapsed} + onSetProjectCollapsed={setProjectCollapsed} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} projects={projects} isRefreshing={isManualRefresh && isRevalidating} @@ -495,23 +509,33 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr - - {({ hovered }) => ( - - )} - + + + + {({ hovered }) => ( + + )} + + + + + Add project + + + + ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + tooltipRow: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing[2], + }, + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.popoverForeground, + }, })) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 723aad733..068f52e1b 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -34,7 +34,6 @@ import { getIsTauri } from '@/constants/layout' import { projectIconQueryKey } from '@/hooks/use-project-icon-query' import { buildHostNewAgentRoute, - buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname, } from '@/utils/host-routes' import { @@ -90,6 +89,7 @@ interface SidebarWorkspaceListProps { serverId: string | null collapsedProjectKeys: ReadonlySet onToggleProjectCollapsed: (projectKey: string) => void + onSetProjectCollapsed: (projectKey: string, collapsed: boolean) => void shortcutIndexByWorkspaceKey: Map isRefreshing?: boolean onRefresh?: () => void @@ -105,9 +105,11 @@ interface ProjectHeaderRowProps { iconDataUri: string | null workspace: SidebarWorkspaceEntry | null selected?: boolean - chevron: 'expand' | 'collapse' | 'disclosure' + chevron: 'expand' | 'collapse' | null onPress: () => void onCreateWorktree?: () => void + shortcutNumber?: number | null + showShortcutBadge?: boolean drag: () => void isDragging: boolean isArchiving?: boolean @@ -184,11 +186,15 @@ function ProjectLeadingVisual({ displayName, iconDataUri, workspace, + chevron = null, + showChevron = false, isArchiving = false, }: { displayName: string iconDataUri: string | null workspace: SidebarWorkspaceEntry | null + chevron?: 'expand' | 'collapse' | null + showChevron?: boolean isArchiving?: boolean }) { const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName) @@ -199,7 +205,9 @@ function ProjectLeadingVisual({ return ( - {shouldShowWorkspaceStatus && activeWorkspace ? ( + {showChevron && chevron !== null ? ( + + ) : shouldShowWorkspaceStatus && activeWorkspace ? ( ) : iconDataUri ? ( @@ -215,8 +223,11 @@ function ProjectLeadingVisual({ function ProjectInlineChevron({ chevron, }: { - chevron: 'expand' | 'collapse' | 'disclosure' + chevron: 'expand' | 'collapse' | null }) { + if (chevron === null) { + return null + } if (chevron === 'collapse') { return } @@ -226,33 +237,47 @@ function ProjectInlineChevron({ function NewWorktreeButton({ displayName, onPress, + visible, testID, }: { displayName: string onPress: () => void + visible: boolean testID: string }) { + const { theme } = useUnistyles() + return ( - - [ - styles.projectIconActionButton, - (hovered || pressed) && styles.projectIconActionButtonHovered, - ]} - onPress={(event) => { - event.stopPropagation() - onPress() - }} - accessibilityRole="button" - accessibilityLabel={`Create a new worktree for ${displayName}`} - testID={testID} - > - - - - New worktree - - + + + + [ + styles.projectIconActionButton, + !visible && styles.projectIconActionButtonHidden, + (hovered || pressed) && styles.projectIconActionButtonHovered, + ]} + onPress={(event) => { + event.stopPropagation() + onPress() + }} + accessibilityRole="button" + accessibilityLabel={`Create a new worktree for ${displayName}`} + testID={testID} + > + {({ hovered, pressed }) => ( + + )} + + + + New worktree + + + ) } @@ -479,12 +504,15 @@ function ProjectHeaderRow({ chevron, onPress, onCreateWorktree, + shortcutNumber = null, + showShortcutBadge = false, drag, isDragging, isArchiving = false, menuController, dragHandleProps, }: ProjectHeaderRowProps) { + const [isHovered, setIsHovered] = useState(false) const interaction = useLongPressDragInteraction({ drag, menuController, @@ -510,34 +538,71 @@ function ProjectHeaderRow({ displayName={displayName} iconDataUri={iconDataUri} workspace={workspace} + chevron={chevron} + showChevron={isHovered && chevron !== null} isArchiving={isArchiving} /> - - {displayName} - - - + + + {displayName} + + {onCreateWorktree ? ( ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} ) if (menuController) { return ( - [ + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + > + [ + styles.projectRow, + isDragging && styles.projectRowDragging, + selected && styles.sidebarRowSelected, + isHovered && styles.projectRowHovered, + pressed && styles.projectRowPressed, + ]} + onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} + onPressOut={interaction.handlePressOut} + onPress={handlePress} + testID={`sidebar-project-row-${project.projectKey}`} + > + {rowChildren} + + + ) + } + + return ( + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + > + [ styles.projectRow, isDragging && styles.projectRowDragging, selected && styles.sidebarRowSelected, - hovered && styles.projectRowHovered, + isHovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ]} onPressIn={interaction.handlePressIn} @@ -547,27 +612,8 @@ function ProjectHeaderRow({ testID={`sidebar-project-row-${project.projectKey}`} > {rowChildren} - - ) - } - - return ( - [ - styles.projectRow, - isDragging && styles.projectRowDragging, - selected && styles.sidebarRowSelected, - hovered && styles.projectRowHovered, - pressed && styles.projectRowPressed, - ]} - onPressIn={interaction.handlePressIn} - onTouchMove={interaction.handleTouchMove} - onPressOut={interaction.handlePressOut} - onPress={handlePress} - testID={`sidebar-project-row-${project.projectKey}`} - > - {rowChildren} - + + ) } @@ -846,6 +892,8 @@ function NonGitProjectRowWithMenuContent({ workspace, selected, onPress, + shortcutNumber, + showShortcutBadge, drag, isDragging, dragHandleProps, @@ -856,6 +904,8 @@ function NonGitProjectRowWithMenuContent({ workspace: SidebarWorkspaceEntry selected: boolean onPress: () => void + shortcutNumber: number | null + showShortcutBadge: boolean drag: () => void isDragging: boolean dragHandleProps?: DraggableListDragHandleProps @@ -909,8 +959,10 @@ function NonGitProjectRowWithMenuContent({ iconDataUri={iconDataUri} workspace={workspace} selected={selected} - chevron="disclosure" + chevron={null} onPress={onPress} + shortcutNumber={shortcutNumber} + showShortcutBadge={showShortcutBadge} drag={drag} isDragging={isDragging} isArchiving={isArchivingWorkspace} @@ -944,6 +996,8 @@ function NonGitProjectRowWithMenu(props: { workspace: SidebarWorkspaceEntry selected: boolean onPress: () => void + shortcutNumber: number | null + showShortcutBadge: boolean drag: () => void isDragging: boolean dragHandleProps?: DraggableListDragHandleProps @@ -955,37 +1009,65 @@ function NonGitProjectRowWithMenu(props: { ) } -function WorkspaceRowPlain({ - workspace, - selected, +function FlattenedProjectRow({ + project, + displayName, + iconDataUri, + rowModel, + onPress, + onCreateWorktree, shortcutNumber, showShortcutBadge, - onPress, drag, isDragging, dragHandleProps, }: { - workspace: SidebarWorkspaceEntry - selected: boolean + project: SidebarProjectEntry + displayName: string + iconDataUri: string | null + rowModel: Extract, { kind: 'workspace_link' }> + onPress: () => void + onCreateWorktree?: () => void shortcutNumber: number | null showShortcutBadge: boolean - onPress: () => void drag: () => void isDragging: boolean dragHandleProps?: DraggableListDragHandleProps }) { + if (project.projectKind === 'non_git') { + return ( + + ) + } + return ( - ) } @@ -1073,11 +1155,9 @@ function ProjectBlock({ }), [activeWorkspaceSelection, collapsed, project, serverId] ) - const flattenedWorkspace = rowModel.flattenedWorkspace const renderWorkspaceRow = useCallback( (item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps }) => { - const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId) const isSelected = Boolean(serverId) && activeWorkspaceSelection?.serverId === serverId && @@ -1105,6 +1185,7 @@ function ProjectBlock({ }, [ activeWorkspaceSelection, + project.projectKind, onWorkspacePress, serverId, shortcutIndexByWorkspaceKey, @@ -1137,20 +1218,26 @@ function ProjectBlock({ return ( - {flattenedWorkspace ? ( - { if (!serverId) { return } onWorkspacePress?.() - navigateToWorkspace(serverId, flattenedWorkspace.workspaceId) + navigateToWorkspace(serverId, rowModel.workspace.workspaceId) }} + onCreateWorktree={ + rowModel.trailingAction === 'new_worktree' && onCreateWorktree + ? () => onCreateWorktree(project) + : undefined + } + shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null} + showShortcutBadge={showShortcutBadges} drag={drag} isDragging={isDragging} dragHandleProps={dragHandleProps} @@ -1201,6 +1288,7 @@ export function SidebarWorkspaceList({ serverId, collapsedProjectKeys, onToggleProjectCollapsed, + onSetProjectCollapsed, shortcutIndexByWorkspaceKey, isRefreshing = false, onRefresh, @@ -1353,6 +1441,7 @@ export function SidebarWorkspaceList({ if (!serverId || project.projectKind !== 'git') { return } + onSetProjectCollapsed(project.projectKey, false) onWorkspacePress?.() router.push( buildHostNewAgentRoute(serverId, { @@ -1361,7 +1450,7 @@ export function SidebarWorkspaceList({ }) as any ) }, - [serverId, onWorkspacePress] + [onSetProjectCollapsed, serverId, onWorkspacePress] ) const renderProject = useCallback( @@ -1519,6 +1608,13 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, }, + projectTitleGroup: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing[1], + flex: 1, + minWidth: 0, + }, projectIcon: { width: '100%', height: '100%', @@ -1547,8 +1643,8 @@ const styles = StyleSheet.create((theme) => ({ projectTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, - flex: 1, minWidth: 0, + flexShrink: 1, }, projectActionButton: { flexDirection: 'row', @@ -1577,6 +1673,16 @@ const styles = StyleSheet.create((theme) => ({ projectIconActionButtonHovered: { backgroundColor: theme.colors.surface1, }, + projectIconActionButtonHidden: { + opacity: 0, + }, + projectTrailingControlSlot: { + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, projectActionTooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.xs, @@ -1698,11 +1804,16 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[1], alignItems: 'center', justifyContent: 'center', + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.surface2, + backgroundColor: theme.colors.surface0, + flexShrink: 0, }, shortcutBadgeText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, + fontWeight: theme.fontWeight.medium, lineHeight: 14, }, })) diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 8385524a0..a8169ca15 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -243,9 +243,79 @@ export function ToolCallDetailsContent({ ); } } else if (detail?.type === "search") { + const searchSections: ReactNode[] = []; + if (detail.query) { + searchSections.push( + + {detail.query} + + ); + } + if (detail.content) { + searchSections.push( + + + + {detail.content} + + + + ); + } + if (detail.filePaths && detail.filePaths.length > 0) { + searchSections.push( + + {detail.filePaths.join("\n")} + + ); + } + if (detail.webResults && detail.webResults.length > 0) { + searchSections.push( + + + {detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")} + + + ); + } + if (detail.annotations && detail.annotations.length > 0) { + searchSections.push( + + {detail.annotations.join("\n\n")} + + ); + } + sections.push(...searchSections); + } else if (detail?.type === "fetch") { sections.push( - - {detail.query} + + + + + {detail.result ? `${detail.url}\n\n${detail.result}` : detail.url} + + + ); } else if (detail?.type === "plain_text") { diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.ts b/packages/app/src/hooks/use-sidebar-shortcut-model.ts index 0cee13679..8aac29e3f 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.ts +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list' import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts' +import { isSidebarProjectFlattened } from '@/utils/sidebar-project-row-model' export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) @@ -23,10 +24,14 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { useEffect(() => { setCollapsedProjectKeys((prev) => { - const validProjectKeys = new Set(projects.map((project) => project.projectKey)) + const collapsibleProjectKeys = new Set( + projects + .filter((project) => !isSidebarProjectFlattened(project)) + .map((project) => project.projectKey) + ) const next = new Set() for (const key of prev) { - if (validProjectKeys.has(key)) { + if (collapsibleProjectKeys.has(key)) { next.add(key) } } @@ -63,9 +68,22 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { }) }, []) + const setProjectCollapsed = useCallback((projectKey: string, collapsed: boolean) => { + setCollapsedProjectKeys((prev) => { + const next = new Set(prev) + if (collapsed) { + next.add(projectKey) + } else { + next.delete(projectKey) + } + return next + }) + }, []) + return { collapsedProjectKeys, shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey, + setProjectCollapsed, toggleProjectCollapsed, } } diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index bb733e05f..a298d39b4 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { buildSidebarProjectRowModel } from './sidebar-project-row-model' +import { + buildSidebarProjectRowModel, + isSidebarProjectFlattened, +} from './sidebar-project-row-model' import type { SidebarProjectEntry, SidebarWorkspaceEntry, @@ -51,11 +54,11 @@ describe('buildSidebarProjectRowModel', () => { }) expect(result).toEqual({ - interaction: 'navigate', - chevron: 'disclosure', - trailingAction: 'none', - flattenedWorkspace, + kind: 'workspace_link', + workspace: flattenedWorkspace, selected: false, + chevron: null, + trailingAction: 'none', }) }) @@ -78,10 +81,36 @@ describe('buildSidebarProjectRowModel', () => { }, }) - expect(result.selected).toBe(true) + expect(result).toMatchObject({ + kind: 'workspace_link', + selected: true, + }) }) - it('keeps git projects as expandable sections with a new worktree action', () => { + it('flattens git projects with a single workspace and keeps the new worktree action', () => { + const flattenedWorkspace = workspace({ + workspaceId: '/repo/main', + workspaceKind: 'local_checkout', + }) + + const result = buildSidebarProjectRowModel({ + project: project({ + projectKind: 'git', + workspaces: [flattenedWorkspace], + }), + collapsed: true, + }) + + expect(result).toEqual({ + kind: 'workspace_link', + workspace: flattenedWorkspace, + selected: false, + chevron: null, + trailingAction: 'new_worktree', + }) + }) + + it('keeps multi-workspace git projects as expandable sections with a new worktree action', () => { const result = buildSidebarProjectRowModel({ project: project({ projectKind: 'git', @@ -94,11 +123,28 @@ describe('buildSidebarProjectRowModel', () => { }) expect(result).toEqual({ - interaction: 'toggle', + kind: 'project_section', chevron: 'expand', trailingAction: 'new_worktree', - flattenedWorkspace: null, - selected: false, }) }) }) + +describe('isSidebarProjectFlattened', () => { + it('returns true for single-workspace projects regardless of kind', () => { + expect(isSidebarProjectFlattened(project({ projectKind: 'git', workspaces: [workspace()] }))).toBe(true) + expect( + isSidebarProjectFlattened(project({ projectKind: 'non_git', workspaces: [workspace()] })) + ).toBe(true) + }) + + it('returns false for multi-workspace projects', () => { + expect( + isSidebarProjectFlattened( + project({ + workspaces: [workspace({ workspaceId: '/repo/main' }), workspace({ workspaceId: '/repo/feat' })], + }) + ) + ).toBe(false) + }) +}) diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 5fcc8cba6..c2610b14b 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -3,12 +3,26 @@ import type { SidebarWorkspaceEntry, } from '@/hooks/use-sidebar-workspaces-list' -export interface SidebarProjectRowModel { - interaction: 'toggle' | 'navigate' - chevron: 'expand' | 'collapse' | 'disclosure' - trailingAction: 'new_worktree' | 'none' - flattenedWorkspace: SidebarWorkspaceEntry | null +export interface SidebarProjectWorkspaceLinkRowModel { + kind: 'workspace_link' + workspace: SidebarWorkspaceEntry selected: boolean + chevron: null + trailingAction: 'new_worktree' | 'none' +} + +export interface SidebarProjectSectionRowModel { + kind: 'project_section' + chevron: 'expand' | 'collapse' + trailingAction: 'new_worktree' | 'none' +} + +export type SidebarProjectRowModel = + | SidebarProjectWorkspaceLinkRowModel + | SidebarProjectSectionRowModel + +export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean { + return project.workspaces.length === 1 } export function buildSidebarProjectRowModel(input: { @@ -17,10 +31,9 @@ export function buildSidebarProjectRowModel(input: { serverId?: string | null activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null }): SidebarProjectRowModel { - const flattenedWorkspace = - input.project.projectKind === 'non_git' && input.project.workspaces.length === 1 - ? input.project.workspaces[0] ?? null - : null + const flattenedWorkspace = isSidebarProjectFlattened(input.project) + ? (input.project.workspaces[0] ?? null) + : null const selected = flattenedWorkspace !== null && Boolean(input.serverId) && @@ -29,19 +42,17 @@ export function buildSidebarProjectRowModel(input: { if (flattenedWorkspace) { return { - interaction: 'navigate', - chevron: 'disclosure', - trailingAction: 'none', - flattenedWorkspace, + kind: 'workspace_link', + workspace: flattenedWorkspace, selected, + chevron: null, + trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none', } } return { - interaction: 'toggle', + kind: 'project_section', chevron: input.collapsed ? 'expand' : 'collapse', trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none', - flattenedWorkspace: null, - selected: false, } } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 60c431676..fd3309e1f 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -34,7 +34,7 @@ describe("buildSidebarShortcutModel", () => { it("builds shortcut targets in visual order and excludes collapsed projects", () => { const projects = [ project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]), - project("p2", [workspace("s1", "/repo2/main")]), + project("p2", [workspace("s1", "/repo2/main"), workspace("s1", "/repo2/feat-a")]), ]; const model = buildSidebarShortcutModel({ @@ -70,4 +70,16 @@ describe("buildSidebarShortcutModel", () => { expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" }); expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" }); }); + + it("ignores collapsed state for flattened single-workspace projects", () => { + const projects = [project("p1", [workspace("s1", "/repo/main")])]; + + const model = buildSidebarShortcutModel({ + projects, + collapsedProjectKeys: new Set(["p1"]), + }); + + expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); + expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]); + }); }); diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index 66e645341..648e85c08 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -2,6 +2,7 @@ import type { SidebarProjectEntry, SidebarWorkspaceEntry, } from '@/hooks/use-sidebar-workspaces-list' +import { isSidebarProjectFlattened } from './sidebar-project-row-model' export interface SidebarShortcutWorkspaceTarget { serverId: string @@ -34,7 +35,7 @@ export function buildSidebarShortcutModel(input: { const shortcutIndexByWorkspaceKey = new Map() for (const project of input.projects) { - if (input.collapsedProjectKeys.has(project.projectKey)) { + if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) { continue } diff --git a/packages/app/src/utils/tauri-daemon-transport.test.ts b/packages/app/src/utils/tauri-daemon-transport.test.ts index a9d5e30f7..4cceafe72 100644 --- a/packages/app/src/utils/tauri-daemon-transport.test.ts +++ b/packages/app/src/utils/tauri-daemon-transport.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const tauriMock = vi.hoisted(() => { let listenerCleanup: (() => void) | null = null; + let disconnectCount = 0; const connect = vi.fn(async () => { let listenerActive = false; @@ -21,6 +22,7 @@ const tauriMock = vi.hoisted(() => { }), send: vi.fn(async () => undefined), disconnect: vi.fn(async () => { + disconnectCount += 1; listenerCleanup?.(); }), }; @@ -29,6 +31,11 @@ const tauriMock = vi.hoisted(() => { return { connect, getListenerCleanup: () => listenerCleanup, + getDisconnectCount: () => disconnectCount, + resetState: () => { + listenerCleanup = null; + disconnectCount = 0; + }, }; }); @@ -43,6 +50,7 @@ vi.mock("./tauri", () => ({ describe("tauri-daemon-transport", () => { beforeEach(() => { tauriMock.connect.mockClear(); + tauriMock.resetState(); }); it("does not unregister the websocket listener twice during close", async () => { @@ -54,6 +62,82 @@ describe("tauri-daemon-transport", () => { await Promise.resolve(); expect(() => transport.close()).not.toThrow(); + await Promise.resolve(); expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1); }); + + it("disconnects the websocket only once after the connection is already open", async () => { + const mod = await import("./tauri-daemon-transport"); + const transportFactory = mod.createTauriWebSocketTransportFactory(); + expect(transportFactory).not.toBeNull(); + + const transport = transportFactory!({ url: "ws://localhost:6767/ws" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(() => transport.close()).not.toThrow(); + await Promise.resolve(); + + expect(tauriMock.getDisconnectCount()).toBe(1); + expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1); + }); + + it("disconnects only once when close happens during async connect cleanup", async () => { + let resolveConnect!: (socket: any) => void; + let resolveSend!: () => void; + + tauriMock.connect.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveConnect = resolve; + }) + ); + + const mod = await import("./tauri-daemon-transport"); + const transportFactory = mod.createTauriWebSocketTransportFactory(); + expect(transportFactory).not.toBeNull(); + + const disconnect = vi.fn(async () => undefined); + const addListener = vi.fn(() => vi.fn()); + const send = vi.fn( + () => + new Promise((resolve) => { + resolveSend = resolve; + }) + ); + + const transport = transportFactory!({ url: "ws://localhost:6767/ws" }); + transport.send("queued-before-connect"); + + resolveConnect({ addListener, send, disconnect }); + await Promise.resolve(); + + transport.close(); + resolveSend(); + await Promise.resolve(); + await Promise.resolve(); + + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it("swallows the tauri duplicate-listener cleanup error when disconnect throws synchronously", async () => { + tauriMock.connect.mockImplementationOnce(async () => ({ + addListener: vi.fn(() => vi.fn()), + send: vi.fn(async () => undefined), + disconnect: vi.fn(() => { + throw new TypeError( + "undefined is not an object (evaluating 'listeners[eventId].handlerId')" + ); + }), + })); + + const mod = await import("./tauri-daemon-transport"); + const transportFactory = mod.createTauriWebSocketTransportFactory(); + expect(transportFactory).not.toBeNull(); + + const transport = transportFactory!({ url: "ws://localhost:6767/ws" }); + await Promise.resolve(); + + expect(() => transport.close()).not.toThrow(); + }); }); diff --git a/packages/app/src/utils/tauri-daemon-transport.ts b/packages/app/src/utils/tauri-daemon-transport.ts index bcf2ae62d..b845620ea 100644 --- a/packages/app/src/utils/tauri-daemon-transport.ts +++ b/packages/app/src/utils/tauri-daemon-transport.ts @@ -18,6 +18,13 @@ type TauriWebSocketModule = { connect(url: string, config?: unknown): Promise; }; +function isTauriDuplicateListenerCleanupError(error: unknown): boolean { + return ( + error instanceof TypeError && + error.message.includes("listeners[eventId].handlerId") + ); +} + function toTauriOutgoingMessage( data: string | Uint8Array | ArrayBuffer ): string | number[] { @@ -49,6 +56,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | let disposed = false; let opened = false; let closeEmitted = false; + let disconnectPromise: Promise | null = null; const openHandlers = new Set<() => void>(); const closeHandlers = new Set<(event?: unknown) => void>(); @@ -104,6 +112,21 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | } }; + const disconnectSocket = (socket: TauriWebSocketConnection) => { + if (disconnectPromise) { + return disconnectPromise; + } + disconnectPromise = Promise.resolve() + .then(() => socket.disconnect()) + .catch((error) => { + if (!isTauriDuplicateListenerCleanupError(error)) { + emitError(error); + } + }) + .then(() => undefined); + return disconnectPromise; + }; + const connect = async () => { try { let module: TauriWebSocketModule | null = getTauriWebSocketModule(); @@ -122,11 +145,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | const socket = await module.connect(url); if (disposed) { - try { - await socket.disconnect(); - } catch { - // no-op - } + await disconnectSocket(socket); return; } @@ -160,13 +179,8 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | } if (closeRequested) { - try { - await ws.disconnect(); - } catch (error) { - emitError(error); - } finally { - emitClose(closeRequested); - } + await disconnectSocket(ws); + emitClose(closeRequested); } } catch (error) { emitError(error); @@ -189,10 +203,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | if (disposed) return; closeRequested = { code, reason }; if (!ws) return; - void ws - .disconnect() - .catch((error) => emitError(error)) - .finally(() => emitClose(closeRequested)); + void disconnectSocket(ws).finally(() => emitClose(closeRequested)); }, onMessage: (handler) => { messageHandlers.add(handler); @@ -230,7 +241,13 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | ...transport, close: (code?: number, reason?: string) => { wsListenerCleanup = null; - transport.close(code, reason); + try { + transport.close(code, reason); + } catch (error) { + if (!isTauriDuplicateListenerCleanupError(error)) { + throw error; + } + } disposed = true; ws = null; }, diff --git a/packages/app/src/utils/tool-call-detail-state.test.ts b/packages/app/src/utils/tool-call-detail-state.test.ts index e5b69811b..b61075e69 100644 --- a/packages/app/src/utils/tool-call-detail-state.test.ts +++ b/packages/app/src/utils/tool-call-detail-state.test.ts @@ -57,4 +57,26 @@ describe("tool-call detail state", () => { false ); }); + + it("treats enriched search detail as meaningful", () => { + const detail: ToolCallDetail = { + type: "search", + query: "needle", + toolName: "grep", + content: "12:needle", + filePaths: ["src/index.ts"], + }; + + assert.strictEqual(hasMeaningfulToolCallDetail(detail), true); + }); + + it("treats fetch detail as meaningful", () => { + const detail: ToolCallDetail = { + type: "fetch", + url: "https://example.com", + result: "Fetched summary", + }; + + assert.strictEqual(hasMeaningfulToolCallDetail(detail), true); + }); }); diff --git a/packages/app/src/utils/tool-call-detail-state.ts b/packages/app/src/utils/tool-call-detail-state.ts index 3e4929d4f..34176374b 100644 --- a/packages/app/src/utils/tool-call-detail-state.ts +++ b/packages/app/src/utils/tool-call-detail-state.ts @@ -38,7 +38,15 @@ export function hasMeaningfulToolCallDetail( case "write": return Boolean(detail.filePath || detail.content); case "search": - return detail.query.trim().length > 0; + return Boolean( + detail.query.trim().length > 0 || + detail.content || + (detail.filePaths && detail.filePaths.length > 0) || + (detail.webResults && detail.webResults.length > 0) || + (detail.annotations && detail.annotations.length > 0) + ); + case "fetch": + return Boolean(detail.url || detail.result || detail.codeText); case "worktree_setup": return Boolean(detail.branchName || detail.worktreePath || detail.log); case "sub_agent": diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index 9b6d1d1f1..8fe0493eb 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -20,6 +20,7 @@ const TOOL_DETAIL_ICONS: Record = edit: Pencil, write: Pencil, search: Search, + fetch: Search, worktree_setup: SquareTerminal, sub_agent: Bot, plain_text: Wrench, diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 4b1538198..2c9caf9c7 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2629,7 +2629,7 @@ dependencies = [ [[package]] name = "paseo" -version = "0.1.29" +version = "0.1.30" dependencies = [ "base64 0.22.1", "dirs", diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 4956c507b..6e1045575 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -147,6 +147,30 @@ export type ToolCallDetail = | { type: "search"; query: string; + toolName?: "search" | "grep" | "glob" | "web_search"; + content?: string; + filePaths?: string[]; + webResults?: Array<{ + title: string; + url: string; + }>; + annotations?: string[]; + numFiles?: number; + numMatches?: number; + durationMs?: number; + durationSeconds?: number; + truncated?: boolean; + mode?: "content" | "files_with_matches" | "count"; + } + | { + type: "fetch"; + url: string; + prompt?: string; + result?: string; + code?: number; + codeText?: string; + bytes?: number; + durationMs?: number; } | { type: "worktree_setup"; diff --git a/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts index 88965ba15..2c5698ebb 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-detail-parser.ts @@ -7,11 +7,17 @@ import { ToolReadInputSchema, ToolReadOutputSchema, ToolSearchInputSchema, + ToolGrepOutputSchema, + ToolGlobOutputSchema, ToolShellInputSchema, ToolShellOutputSchema, + ToolWebFetchInputSchema, + ToolWebFetchOutputSchema, + ToolWebSearchOutputSchema, ToolWriteInputSchema, ToolWriteOutputSchema, toEditToolDetail, + toFetchToolDetail, toReadToolDetail, toSearchToolDetail, toShellToolDetail, @@ -87,27 +93,32 @@ const ClaudeToolDetailPass2Schema = z.union([ ToolEditOutputSchema, toEditToolDetail ), - toolDetailBranchByName("WebSearch", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toolDetailBranchByName("WebSearch", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) => + toSearchToolDetail({ input, output, toolName: "web_search" }) ), - toolDetailBranchByName("web_search", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toolDetailBranchByName("web_search", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) => + toSearchToolDetail({ input, output, toolName: "web_search" }) ), toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toSearchToolDetail({ input, toolName: "search" }) ), - toolDetailBranchByName("Grep", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toolDetailBranchByName("Grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) => + toSearchToolDetail({ input, output, toolName: "grep" }) ), - toolDetailBranchByName("grep", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toolDetailBranchByName("grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) => + toSearchToolDetail({ input, output, toolName: "grep" }) ), - toolDetailBranchByName("Glob", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toolDetailBranchByName("Glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) => + toSearchToolDetail({ input, output, toolName: "glob" }) ), - toolDetailBranchByName("glob", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toolDetailBranchByName("glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) => + toSearchToolDetail({ input, output, toolName: "glob" }) ), + toolDetailBranchByName("WebFetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail), + toolDetailBranchByName("web_fetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail), + toolDetailBranchByName("WebFetchTool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail), + toolDetailBranchByName("web_fetch_tool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail), + toolDetailBranchByName("webfetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail), toolDetailBranchByName( "Skill", z.object({ skill: z.string() }).passthrough(), diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts index ab5e88f06..5487508aa 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts @@ -103,6 +103,7 @@ describe("claude tool-call mapper", () => { expect(searchItem.detail).toEqual({ type: "search", query: "tool call mapping", + toolName: "web_search", }); }); @@ -220,6 +221,7 @@ describe("claude tool-call mapper", () => { expect(searchItem.detail).toEqual({ type: "search", query: "tool call mapping", + toolName: "web_search", }); }); @@ -248,7 +250,12 @@ describe("claude tool-call mapper", () => { callId: "claude-glob-1", name: "Glob", input: { pattern: "**/.claude/commands/paseo*" }, - output: { output: "No files found" }, + output: { + durationMs: 7, + numFiles: 2, + filenames: ["a.txt", "b.txt"], + truncated: false, + }, }) ); @@ -258,6 +265,11 @@ describe("claude tool-call mapper", () => { expect(item.detail).toEqual({ type: "search", query: "**/.claude/commands/paseo*", + toolName: "glob", + filePaths: ["a.txt", "b.txt"], + numFiles: 2, + durationMs: 7, + truncated: false, }); }); @@ -272,7 +284,14 @@ describe("claude tool-call mapper", () => { output_mode: "content", "-n": true, }, - output: { output: "No matches found" }, + output: { + mode: "content", + numFiles: 1, + filenames: ["src/main.rs"], + content: "12:const cli = true;", + numLines: 1, + numMatches: 1, + }, }) ); @@ -282,6 +301,80 @@ describe("claude tool-call mapper", () => { expect(item.detail).toEqual({ type: "search", query: '\\\\\\"cli\\\\\\""', + toolName: "grep", + content: "12:const cli = true;", + filePaths: ["src/main.rs"], + numFiles: 1, + numMatches: 1, + mode: "content", + }); + }); + + it("maps WebSearch calls with structured results", () => { + const item = expectMapped( + mapClaudeCompletedToolCall({ + callId: "claude-web-search-1", + name: "WebSearch", + input: { query: "OpenAI latest news" }, + output: { + query: "OpenAI latest news", + results: [ + "Top results:", + { + tool_use_id: "toolu_123", + content: [ + { title: "OpenAI launches thing", url: "https://example.com/1" }, + { title: "Another result", url: "https://example.com/2" }, + ], + }, + ], + durationSeconds: 1.5, + }, + }) + ); + + expect(item.detail).toEqual({ + type: "search", + query: "OpenAI latest news", + toolName: "web_search", + webResults: [ + { title: "OpenAI launches thing", url: "https://example.com/1" }, + { title: "Another result", url: "https://example.com/2" }, + ], + annotations: ["Top results:"], + durationSeconds: 1.5, + }); + }); + + it("maps WebFetch calls with fetched content", () => { + const item = expectMapped( + mapClaudeCompletedToolCall({ + callId: "claude-web-fetch-1", + name: "WebFetch", + input: { + url: "https://example.com/article", + prompt: "Summarize this page", + }, + output: { + bytes: 5120, + code: 200, + codeText: "OK", + result: "Summary text", + durationMs: 250, + url: "https://example.com/article", + }, + }) + ); + + expect(item.detail).toEqual({ + type: "fetch", + url: "https://example.com/article", + prompt: "Summarize this page", + result: "Summary text", + code: 200, + codeText: "OK", + bytes: 5120, + durationMs: 250, }); }); diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts index f6c162dd5..83b2e3fd6 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts @@ -80,6 +80,13 @@ const ClaudeSearchToolNameSchema = z.union([ z.literal("Glob"), z.literal("glob"), ]); +const ClaudeFetchToolNameSchema = z.union([ + z.literal("WebFetch"), + z.literal("web_fetch"), + z.literal("WebFetchTool"), + z.literal("web_fetch_tool"), + z.literal("webfetch"), +]); const ClaudeSpeakToolNameSchema = z .string() .min(1) @@ -91,6 +98,7 @@ const ClaudeToolKindSchema = z.enum([ "write", "edit", "search", + "fetch", "speak", "unknown", ]); @@ -146,6 +154,13 @@ const ClaudeToolCallPass2EnvelopeSchema = z.union([ name: normalized.name.trim(), toolKind: "search" as const, })), + ClaudeToolCallPass2InputSchema.extend({ + name: ClaudeFetchToolNameSchema, + }).transform((normalized) => ({ + ...normalized, + name: normalized.name.trim(), + toolKind: "fetch" as const, + })), ClaudeToolCallPass2InputSchema.extend({ name: ClaudeSpeakToolNameSchema, }).transform((normalized) => ({ @@ -181,6 +196,10 @@ const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [ toolKind: z.literal("search"), name: ClaudeSearchToolNameSchema, }), + ClaudeToolCallPass2BaseSchema.extend({ + toolKind: z.literal("fetch"), + name: ClaudeFetchToolNameSchema, + }), ClaudeToolCallPass2BaseSchema.extend({ toolKind: z.literal("speak"), name: z.literal("speak"), @@ -203,8 +222,8 @@ function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimeli ? "write_file" : normalized.toolKind === "edit" ? "apply_patch" - : normalized.toolKind === "search" - ? "search" + : normalized.toolKind === "search" || normalized.toolKind === "fetch" + ? normalized.name : normalized.toolKind === "speak" ? "speak" : normalized.name; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 08525f21f..e2d0b3743 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -135,6 +135,10 @@ function normalizeCodexModelId(modelId: string | null | undefined): string | und return normalized; } +function normalizeCodexModelLabel(displayName: string): string { + return displayName.replace(/\bgpt\b/gi, 'GPT'); +} + type CodexConfiguredDefaults = { model?: string; thinkingOptionId?: string; @@ -3389,7 +3393,7 @@ export class CodexAppServerAgentClient implements AgentClient { return { provider: CODEX_PROVIDER, id: model.id, - label: model.displayName, + label: normalizeCodexModelLabel(model.displayName), description: model.description, isDefault: isDefaultModel, thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, diff --git a/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts index 6ccd2e965..ce8d4f99b 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-detail-parser.ts @@ -150,10 +150,10 @@ const CodexToolDetailPass2Schema = z.union([ (input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }) ), toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toSearchToolDetail({ input, toolName: "search" }) ), toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toSearchToolDetail({ input, toolName: "web_search" }) ), CodexSpeakToolDetailSchema, ]); diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 2fa80c12e..96b048206 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -151,6 +151,7 @@ describe("codex tool-call mapper", () => { expect(searchItem?.detail).toEqual({ type: "search", query: "codex timeline", + toolName: "web_search", }); }); @@ -338,6 +339,7 @@ describe("codex tool-call mapper", () => { expect(searchItem?.detail).toEqual({ type: "search", query: "codex timeline", + toolName: "web_search", }); }); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index 4143cb746..a98de3520 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -38,10 +38,10 @@ const OpencodeKnownToolDetailSchema = z.union([ toolDetailBranchByToolName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail), toolDetailBranchByToolName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail), toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toSearchToolDetail({ input, toolName: "search" }) ), toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) => - toSearchToolDetail(input) + toSearchToolDetail({ input, toolName: "web_search" }) ), ]); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts index 9f179acc2..fbb2527b3 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -86,6 +86,7 @@ describe("opencode tool-call mapper", () => { expect(searchItem.detail).toEqual({ type: "search", query: "opencode mapper", + toolName: "web_search", }); }); @@ -210,6 +211,7 @@ describe("opencode tool-call mapper", () => { expect(searchItem.detail).toEqual({ type: "search", query: "opencode mapper", + toolName: "web_search", }); }); diff --git a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts index 2ec8d170d..26f485094 100644 --- a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts +++ b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts @@ -512,6 +512,71 @@ export const ToolSearchInputSchema = z.union([ z.object({ pattern: z.string() }).passthrough().transform((value) => ({ query: value.pattern })), ]); +export const ToolGlobOutputSchema = z + .object({ + durationMs: z.number().finite(), + numFiles: z.number().int().nonnegative(), + filenames: z.array(z.string()), + truncated: z.boolean(), + }) + .passthrough(); + +export const ToolGrepOutputSchema = z + .object({ + mode: z.enum(["content", "files_with_matches", "count"]).optional(), + numFiles: z.number().int().nonnegative(), + filenames: z.array(z.string()), + content: z.string().optional(), + numLines: z.number().int().nonnegative().optional(), + numMatches: z.number().int().nonnegative().optional(), + appliedLimit: z.number().int().nonnegative().optional(), + appliedOffset: z.number().int().nonnegative().optional(), + }) + .passthrough(); + +export const ToolWebFetchInputSchema = z + .object({ + url: z.string(), + prompt: z.string(), + }) + .passthrough(); + +export const ToolWebFetchOutputSchema = z + .object({ + bytes: z.number().int().nonnegative(), + code: z.number().int(), + codeText: z.string(), + result: z.string(), + durationMs: z.number().finite(), + url: z.string(), + }) + .passthrough(); + +const ToolWebSearchResultHitSchema = z + .object({ + title: z.string(), + url: z.string(), + }) + .passthrough(); + +const ToolWebSearchResultEntrySchema = z.union([ + z + .object({ + tool_use_id: z.string(), + content: z.array(ToolWebSearchResultHitSchema), + }) + .passthrough(), + z.string(), +]); + +export const ToolWebSearchOutputSchema = z + .object({ + query: z.string(), + results: z.array(ToolWebSearchResultEntrySchema), + durationSeconds: z.number().finite(), + }) + .passthrough(); + export type ParsedToolShellInput = z.infer; export type ParsedToolShellOutput = z.infer; export type ParsedToolReadInput = z.infer; @@ -522,6 +587,11 @@ export type ParsedToolWriteOutput = z.infer; export type ParsedToolEditInput = z.infer; export type ParsedToolEditOutput = z.infer; export type ParsedToolSearchInput = z.infer; +export type ParsedToolGlobOutput = z.infer; +export type ParsedToolGrepOutput = z.infer; +export type ParsedToolWebFetchInput = z.infer; +export type ParsedToolWebFetchOutput = z.infer; +export type ParsedToolWebSearchOutput = z.infer; type NormalizePathFn = (filePath: string) => string | undefined; @@ -539,6 +609,24 @@ function normalizeDetailPath( return normalizePath ? normalizePath(trimmed) : trimmed; } +function isParsedToolGlobOutput( + output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined +): output is ParsedToolGlobOutput { + return Boolean(output && "truncated" in output); +} + +function isParsedToolGrepOutput( + output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined +): output is ParsedToolGrepOutput { + return Boolean(output && "filenames" in output && !("truncated" in output)); +} + +function isParsedToolWebSearchOutput( + output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined +): output is ParsedToolWebSearchOutput { + return Boolean(output && "results" in output); +} + export function toShellToolDetail( input: ParsedToolShellInput | null, output: ParsedToolShellOutput | null @@ -625,15 +713,70 @@ export function toEditToolDetail( }; } -export function toSearchToolDetail( - input: ParsedToolSearchInput | null -): ToolCallDetail | undefined { +export function toSearchToolDetail(params: { + input: ParsedToolSearchInput | null; + output?: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null; + toolName?: "search" | "grep" | "glob" | "web_search"; +}): ToolCallDetail | undefined { + const { input, output, toolName } = params; if (!input?.query) { return undefined; } + + const filePaths = + isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output) + ? output.filenames.length > 0 + ? output.filenames + : undefined + : undefined; + const webResults = isParsedToolWebSearchOutput(output) + ? output.results.flatMap((entry) => (typeof entry === "string" ? [] : entry.content)) + : undefined; + const annotations = isParsedToolWebSearchOutput(output) + ? output.results.filter((entry): entry is string => typeof entry === "string") + : undefined; + return { type: "search", query: input.query, + ...(toolName ? { toolName } : {}), + ...(isParsedToolGrepOutput(output) && output.content ? { content: output.content } : {}), + ...(filePaths ? { filePaths } : {}), + ...(webResults && webResults.length > 0 ? { webResults } : {}), + ...(annotations && annotations.length > 0 ? { annotations } : {}), + ...(isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output) + ? { numFiles: output.numFiles } + : {}), + ...(isParsedToolGrepOutput(output) && output.numMatches !== undefined + ? { numMatches: output.numMatches } + : {}), + ...(isParsedToolGlobOutput(output) ? { durationMs: output.durationMs } : {}), + ...(isParsedToolWebSearchOutput(output) + ? { durationSeconds: output.durationSeconds } + : {}), + ...(isParsedToolGlobOutput(output) ? { truncated: output.truncated } : {}), + ...(isParsedToolGrepOutput(output) && output.mode ? { mode: output.mode } : {}), + }; +} + +export function toFetchToolDetail( + input: ParsedToolWebFetchInput | null, + output: ParsedToolWebFetchOutput | null +): ToolCallDetail | undefined { + const url = input?.url ?? output?.url; + if (!url) { + return undefined; + } + + return { + type: "fetch", + url, + ...(input?.prompt ? { prompt: input.prompt } : {}), + ...(output?.result ? { result: output.result } : {}), + ...(output?.code !== undefined ? { code: output.code } : {}), + ...(output?.codeText ? { codeText: output.codeText } : {}), + ...(output?.bytes !== undefined ? { bytes: output.bytes } : {}), + ...(output?.durationMs !== undefined ? { durationMs: output.durationMs } : {}), }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 7b125e34c..68f0d29eb 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -181,6 +181,32 @@ const ToolCallDetailPayloadSchema: z.ZodType = z.discriminatedUn z.object({ type: z.literal('search'), query: z.string(), + toolName: z.enum(['search', 'grep', 'glob', 'web_search']).optional(), + content: z.string().optional(), + filePaths: z.array(z.string()).optional(), + webResults: z.array( + z.object({ + title: z.string(), + url: z.string(), + }) + ).optional(), + annotations: z.array(z.string()).optional(), + numFiles: z.number().optional(), + numMatches: z.number().optional(), + durationMs: z.number().optional(), + durationSeconds: z.number().optional(), + truncated: z.boolean().optional(), + mode: z.enum(['content', 'files_with_matches', 'count']).optional(), + }), + z.object({ + type: z.literal('fetch'), + url: z.string(), + prompt: z.string().optional(), + result: z.string().optional(), + code: z.number().optional(), + codeText: z.string().optional(), + bytes: z.number().optional(), + durationMs: z.number().optional(), }), z.object({ type: z.literal('worktree_setup'), diff --git a/packages/server/src/shared/tool-call-display.ts b/packages/server/src/shared/tool-call-display.ts index 15f046abd..cc4563101 100644 --- a/packages/server/src/shared/tool-call-display.ts +++ b/packages/server/src/shared/tool-call-display.ts @@ -90,6 +90,11 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay displayName: "Search", summary: input.detail.query, }; + case "fetch": + return { + displayName: "Fetch", + summary: input.detail.url, + }; case "worktree_setup": return { displayName: "Worktree Setup",