Add fetch tool details and improve sidebar shortcuts handling

This commit is contained in:
Mohamed Boudra
2026-03-19 13:04:44 +07:00
parent 675d825f6b
commit 4ab307b10f
26 changed files with 960 additions and 197 deletions
+76 -43
View File
@@ -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
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleOpenProjectMobile}
testID="sidebar-sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<Plus
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
@@ -327,7 +329,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
hovered && styles.newAgentButtonTextHovered,
]}
>
Add project
Sessions
</Text>
</>
)}
@@ -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
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-all-agents"
nativeID="sidebar-all-agents"
collapsable={false}
accessible
accessibilityLabel="Sessions"
accessibilityRole="button"
onPress={handleViewMore}
>
{({ hovered }) => (
<MessagesSquare
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProjectMobile}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
@@ -441,19 +454,19 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<View style={styles.sidebarHeaderRow}>
<Pressable
style={styles.newAgentButton}
testID="sidebar-new-agent"
onPress={handleOpenProjectDesktop}
testID="sidebar-sessions"
onPress={handleViewMore}
>
{({ hovered }) => (
<>
<Plus
<MessagesSquare
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Add project
Sessions
</Text>
</>
)}
@@ -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
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Pressable
style={styles.footerIconButton}
testID="sidebar-all-agents"
nativeID="sidebar-all-agents"
collapsable={false}
accessible
accessibilityLabel="Sessions"
accessibilityRole="button"
onPress={handleViewMore}
>
{({ hovered }) => (
<MessagesSquare
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProjectDesktop}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
<Shortcut keys={['⌘', '⇧', 'O']} />
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
@@ -690,4 +714,13 @@ const styles = StyleSheet.create((theme) => ({
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,
},
}))
@@ -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<string>
onToggleProjectCollapsed: (projectKey: string) => void
onSetProjectCollapsed: (projectKey: string, collapsed: boolean) => void
shortcutIndexByWorkspaceKey: Map<string, number>
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 (
<View style={styles.projectLeadingVisualSlot}>
{shouldShowWorkspaceStatus && activeWorkspace ? (
{showChevron && chevron !== null ? (
<ProjectInlineChevron chevron={chevron} />
) : shouldShowWorkspaceStatus && activeWorkspace ? (
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} />
) : iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
@@ -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 <ChevronDown size={14} color="#9ca3af" />
}
@@ -226,33 +237,47 @@ function ProjectInlineChevron({
function NewWorktreeButton({
displayName,
onPress,
visible,
testID,
}: {
displayName: string
onPress: () => void
visible: boolean
testID: string
}) {
const { theme } = useUnistyles()
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
style={({ hovered, pressed }) => [
styles.projectIconActionButton,
(hovered || pressed) && styles.projectIconActionButtonHovered,
]}
onPress={(event) => {
event.stopPropagation()
onPress()
}}
accessibilityRole="button"
accessibilityLabel={`Create a new worktree for ${displayName}`}
testID={testID}
>
<Plus size={14} color="#9ca3af" />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
</TooltipContent>
</Tooltip>
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? 'auto' : 'none'}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
style={({ hovered, pressed }) => [
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 }) => (
<Plus
size={14}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.projectActionTooltipText}>New worktree</Text>
</TooltipContent>
</Tooltip>
</View>
)
}
@@ -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}
/>
<Text style={styles.projectTitle} numberOfLines={1}>
{displayName}
</Text>
<ProjectInlineChevron chevron={chevron} />
<View style={styles.projectTitleGroup}>
<Text style={styles.projectTitle} numberOfLines={1}>
{displayName}
</Text>
</View>
</View>
{onCreateWorktree ? (
<NewWorktreeButton
displayName={displayName}
onPress={onCreateWorktree}
visible={isHovered}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</>
)
if (menuController) {
return (
<ContextMenuTrigger
enabledOnMobile={false}
style={({ pressed, hovered = false }) => [
<View
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<ContextMenuTrigger
enabledOnMobile={false}
style={({ pressed }) => [
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}
</ContextMenuTrigger>
</View>
)
}
return (
<View
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<Pressable
style={({ pressed }) => [
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}
</ContextMenuTrigger>
)
}
return (
<Pressable
style={({ pressed, hovered = false }) => [
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}
</Pressable>
</Pressable>
</View>
)
}
@@ -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<ReturnType<typeof buildSidebarProjectRowModel>, { 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 (
<NonGitProjectRowWithMenu
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={rowModel.workspace}
selected={rowModel.selected}
onPress={onPress}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
)
}
return (
<WorkspaceRowInner
workspace={workspace}
selected={selected}
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={rowModel.workspace}
selected={rowModel.selected}
chevron={rowModel.chevron}
onPress={onPress}
onCreateWorktree={rowModel.trailingAction === 'new_worktree' ? onCreateWorktree : undefined}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={false}
dragHandleProps={dragHandleProps}
menuController={null}
dragHandleProps={dragHandleProps}
/>
)
}
@@ -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 (
<View style={styles.projectBlock}>
{flattenedWorkspace ? (
<NonGitProjectRowWithMenu
{rowModel.kind === 'workspace_link' ? (
<FlattenedProjectRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={flattenedWorkspace}
selected={rowModel.selected}
rowModel={rowModel}
onPress={() => {
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,
},
}))
@@ -243,9 +243,79 @@ export function ToolCallDetailsContent({
);
}
} else if (detail?.type === "search") {
const searchSections: ReactNode[] = [];
if (detail.query) {
searchSections.push(
<View key="search-query" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.query}</Text>
</View>
);
}
if (detail.content) {
searchSections.push(
<View key="search-content" style={styles.section}>
<ScrollView
style={[
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<Text selectable style={styles.scrollText}>{detail.content}</Text>
</ScrollView>
</ScrollView>
</View>
);
}
if (detail.filePaths && detail.filePaths.length > 0) {
searchSections.push(
<View key="search-files" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.filePaths.join("\n")}</Text>
</View>
);
}
if (detail.webResults && detail.webResults.length > 0) {
searchSections.push(
<View key="search-web-results" style={styles.section}>
<Text selectable style={styles.scrollText}>
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
</Text>
</View>
);
}
if (detail.annotations && detail.annotations.length > 0) {
searchSections.push(
<View key="search-annotations" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.annotations.join("\n\n")}</Text>
</View>
);
}
sections.push(...searchSections);
} else if (detail?.type === "fetch") {
sections.push(
<View key="search" style={styles.section}>
<Text selectable style={styles.scrollText}>{detail.query}</Text>
<View
key="fetch"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<ScrollView
style={[
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<Text selectable style={styles.scrollText}>
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
</Text>
</ScrollView>
</ScrollView>
</View>
);
} else if (detail?.type === "plain_text") {
@@ -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<Set<string>>(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<string>()
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,
}
}
@@ -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)
})
})
@@ -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,
}
}
@@ -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<string>(["p1"]),
});
expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
});
});
+2 -1
View File
@@ -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<string, number>()
for (const project of input.projects) {
if (input.collapsedProjectKeys.has(project.projectKey)) {
if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) {
continue
}
@@ -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<void>((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();
});
});
@@ -18,6 +18,13 @@ type TauriWebSocketModule = {
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
};
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<void> | 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;
},
@@ -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);
});
});
@@ -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":
+1
View File
@@ -20,6 +20,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
edit: Pencil,
write: Pencil,
search: Search,
fetch: Search,
worktree_setup: SquareTerminal,
sub_agent: Bot,
plain_text: Wrench,
+1 -1
View File
@@ -2629,7 +2629,7 @@ dependencies = [
[[package]]
name = "paseo"
version = "0.1.29"
version = "0.1.30"
dependencies = [
"base64 0.22.1",
"dirs",
@@ -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";
@@ -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(),
@@ -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,
});
});
@@ -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;
@@ -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,
@@ -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,
]);
@@ -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",
});
});
@@ -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" })
),
]);
@@ -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",
});
});
@@ -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<typeof ToolShellInputSchema>;
export type ParsedToolShellOutput = z.infer<typeof ToolShellOutputSchema>;
export type ParsedToolReadInput = z.infer<typeof ToolReadInputSchema>;
@@ -522,6 +587,11 @@ export type ParsedToolWriteOutput = z.infer<typeof ToolWriteOutputSchema>;
export type ParsedToolEditInput = z.infer<typeof ToolEditInputSchema>;
export type ParsedToolEditOutput = z.infer<typeof ToolEditOutputSchema>;
export type ParsedToolSearchInput = z.infer<typeof ToolSearchInputSchema>;
export type ParsedToolGlobOutput = z.infer<typeof ToolGlobOutputSchema>;
export type ParsedToolGrepOutput = z.infer<typeof ToolGrepOutputSchema>;
export type ParsedToolWebFetchInput = z.infer<typeof ToolWebFetchInputSchema>;
export type ParsedToolWebFetchOutput = z.infer<typeof ToolWebFetchOutputSchema>;
export type ParsedToolWebSearchOutput = z.infer<typeof ToolWebSearchOutputSchema>;
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 } : {}),
};
}
+26
View File
@@ -181,6 +181,32 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = 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'),
@@ -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",