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",