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 { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { MessagesSquare, Plus, Settings } from 'lucide-react-native' 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 { router, usePathname } from 'expo-router'
import { usePanelStore } from '@/stores/panel-store' import { usePanelStore } from '@/stores/panel-store'
import { SidebarWorkspaceList } from './sidebar-workspace-list' import { SidebarWorkspaceList } from './sidebar-workspace-list'
@@ -117,7 +119,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
serverId: activeServerId, serverId: activeServerId,
enabled: isOpen, enabled: isOpen,
}) })
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } = const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
useSidebarShortcutModel(projects) useSidebarShortcutModel(projects)
const { const {
translateX, translateX,
@@ -312,12 +314,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<View style={styles.sidebarHeaderRow}> <View style={styles.sidebarHeaderRow}>
<Pressable <Pressable
style={styles.newAgentButton} style={styles.newAgentButton}
testID="sidebar-new-agent" testID="sidebar-sessions"
onPress={handleOpenProjectMobile} onPress={handleViewMore}
> >
{({ hovered }) => ( {({ hovered }) => (
<> <>
<Plus <MessagesSquare
size={theme.iconSize.md} size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
@@ -327,7 +329,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
hovered && styles.newAgentButtonTextHovered, hovered && styles.newAgentButtonTextHovered,
]} ]}
> >
Add project Sessions
</Text> </Text>
</> </>
)} )}
@@ -343,6 +345,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
serverId={activeServerId} serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys} collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed} onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects} projects={projects}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
@@ -373,23 +376,33 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
</Pressable> </Pressable>
</View> </View>
<View style={styles.footerIconRow}> <View style={styles.footerIconRow}>
<Pressable <Tooltip delayDuration={300}>
style={styles.footerIconButton} <TooltipTrigger asChild>
testID="sidebar-all-agents" <Pressable
nativeID="sidebar-all-agents" style={styles.footerIconButton}
collapsable={false} testID="sidebar-add-project"
accessible nativeID="sidebar-add-project"
accessibilityLabel="Sessions" collapsable={false}
accessibilityRole="button" accessible
onPress={handleViewMore} accessibilityLabel="Add project"
> accessibilityRole="button"
{({ hovered }) => ( onPress={handleOpenProjectMobile}
<MessagesSquare >
size={theme.iconSize.lg} {({ hovered }) => (
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} <Plus
/> size={theme.iconSize.lg}
)} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
</Pressable> />
)}
</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 <Pressable
style={styles.footerIconButton} style={styles.footerIconButton}
testID="sidebar-settings" testID="sidebar-settings"
@@ -441,19 +454,19 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<View style={styles.sidebarHeaderRow}> <View style={styles.sidebarHeaderRow}>
<Pressable <Pressable
style={styles.newAgentButton} style={styles.newAgentButton}
testID="sidebar-new-agent" testID="sidebar-sessions"
onPress={handleOpenProjectDesktop} onPress={handleViewMore}
> >
{({ hovered }) => ( {({ hovered }) => (
<> <>
<Plus <MessagesSquare
size={theme.iconSize.md} size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
<Text <Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]} style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
> >
Add project Sessions
</Text> </Text>
</> </>
)} )}
@@ -469,6 +482,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
serverId={activeServerId} serverId={activeServerId}
collapsedProjectKeys={collapsedProjectKeys} collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed} onToggleProjectCollapsed={toggleProjectCollapsed}
onSetProjectCollapsed={setProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
projects={projects} projects={projects}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
@@ -495,23 +509,33 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
</Pressable> </Pressable>
</View> </View>
<View style={styles.footerIconRow}> <View style={styles.footerIconRow}>
<Pressable <Tooltip delayDuration={300}>
style={styles.footerIconButton} <TooltipTrigger asChild>
testID="sidebar-all-agents" <Pressable
nativeID="sidebar-all-agents" style={styles.footerIconButton}
collapsable={false} testID="sidebar-add-project"
accessible nativeID="sidebar-add-project"
accessibilityLabel="Sessions" collapsable={false}
accessibilityRole="button" accessible
onPress={handleViewMore} accessibilityLabel="Add project"
> accessibilityRole="button"
{({ hovered }) => ( onPress={handleOpenProjectDesktop}
<MessagesSquare >
size={theme.iconSize.lg} {({ hovered }) => (
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} <Plus
/> size={theme.iconSize.lg}
)} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
</Pressable> />
)}
</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 <Pressable
style={styles.footerIconButton} style={styles.footerIconButton}
testID="sidebar-settings" testID="sidebar-settings"
@@ -690,4 +714,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, 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 { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import { import {
buildHostNewAgentRoute, buildHostNewAgentRoute,
buildHostWorkspaceRoute,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
} from '@/utils/host-routes' } from '@/utils/host-routes'
import { import {
@@ -90,6 +89,7 @@ interface SidebarWorkspaceListProps {
serverId: string | null serverId: string | null
collapsedProjectKeys: ReadonlySet<string> collapsedProjectKeys: ReadonlySet<string>
onToggleProjectCollapsed: (projectKey: string) => void onToggleProjectCollapsed: (projectKey: string) => void
onSetProjectCollapsed: (projectKey: string, collapsed: boolean) => void
shortcutIndexByWorkspaceKey: Map<string, number> shortcutIndexByWorkspaceKey: Map<string, number>
isRefreshing?: boolean isRefreshing?: boolean
onRefresh?: () => void onRefresh?: () => void
@@ -105,9 +105,11 @@ interface ProjectHeaderRowProps {
iconDataUri: string | null iconDataUri: string | null
workspace: SidebarWorkspaceEntry | null workspace: SidebarWorkspaceEntry | null
selected?: boolean selected?: boolean
chevron: 'expand' | 'collapse' | 'disclosure' chevron: 'expand' | 'collapse' | null
onPress: () => void onPress: () => void
onCreateWorktree?: () => void onCreateWorktree?: () => void
shortcutNumber?: number | null
showShortcutBadge?: boolean
drag: () => void drag: () => void
isDragging: boolean isDragging: boolean
isArchiving?: boolean isArchiving?: boolean
@@ -184,11 +186,15 @@ function ProjectLeadingVisual({
displayName, displayName,
iconDataUri, iconDataUri,
workspace, workspace,
chevron = null,
showChevron = false,
isArchiving = false, isArchiving = false,
}: { }: {
displayName: string displayName: string
iconDataUri: string | null iconDataUri: string | null
workspace: SidebarWorkspaceEntry | null workspace: SidebarWorkspaceEntry | null
chevron?: 'expand' | 'collapse' | null
showChevron?: boolean
isArchiving?: boolean isArchiving?: boolean
}) { }) {
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName) const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName)
@@ -199,7 +205,9 @@ function ProjectLeadingVisual({
return ( return (
<View style={styles.projectLeadingVisualSlot}> <View style={styles.projectLeadingVisualSlot}>
{shouldShowWorkspaceStatus && activeWorkspace ? ( {showChevron && chevron !== null ? (
<ProjectInlineChevron chevron={chevron} />
) : shouldShowWorkspaceStatus && activeWorkspace ? (
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} /> <WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} />
) : iconDataUri ? ( ) : iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} /> <Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
@@ -215,8 +223,11 @@ function ProjectLeadingVisual({
function ProjectInlineChevron({ function ProjectInlineChevron({
chevron, chevron,
}: { }: {
chevron: 'expand' | 'collapse' | 'disclosure' chevron: 'expand' | 'collapse' | null
}) { }) {
if (chevron === null) {
return null
}
if (chevron === 'collapse') { if (chevron === 'collapse') {
return <ChevronDown size={14} color="#9ca3af" /> return <ChevronDown size={14} color="#9ca3af" />
} }
@@ -226,33 +237,47 @@ function ProjectInlineChevron({
function NewWorktreeButton({ function NewWorktreeButton({
displayName, displayName,
onPress, onPress,
visible,
testID, testID,
}: { }: {
displayName: string displayName: string
onPress: () => void onPress: () => void
visible: boolean
testID: string testID: string
}) { }) {
const { theme } = useUnistyles()
return ( return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <View style={styles.projectTrailingControlSlot} pointerEvents={visible ? 'auto' : 'none'}>
<TooltipTrigger <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
style={({ hovered, pressed }) => [ <TooltipTrigger asChild disabled={!visible}>
styles.projectIconActionButton, <Pressable
(hovered || pressed) && styles.projectIconActionButtonHovered, style={({ hovered, pressed }) => [
]} styles.projectIconActionButton,
onPress={(event) => { !visible && styles.projectIconActionButtonHidden,
event.stopPropagation() (hovered || pressed) && styles.projectIconActionButtonHovered,
onPress() ]}
}} onPress={(event) => {
accessibilityRole="button" event.stopPropagation()
accessibilityLabel={`Create a new worktree for ${displayName}`} onPress()
testID={testID} }}
> accessibilityRole="button"
<Plus size={14} color="#9ca3af" /> accessibilityLabel={`Create a new worktree for ${displayName}`}
</TooltipTrigger> testID={testID}
<TooltipContent side="bottom" align="end" offset={8}> >
<Text style={styles.projectActionTooltipText}>New worktree</Text> {({ hovered, pressed }) => (
</TooltipContent> <Plus
</Tooltip> 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, chevron,
onPress, onPress,
onCreateWorktree, onCreateWorktree,
shortcutNumber = null,
showShortcutBadge = false,
drag, drag,
isDragging, isDragging,
isArchiving = false, isArchiving = false,
menuController, menuController,
dragHandleProps, dragHandleProps,
}: ProjectHeaderRowProps) { }: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false)
const interaction = useLongPressDragInteraction({ const interaction = useLongPressDragInteraction({
drag, drag,
menuController, menuController,
@@ -510,34 +538,71 @@ function ProjectHeaderRow({
displayName={displayName} displayName={displayName}
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
workspace={workspace} workspace={workspace}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving} isArchiving={isArchiving}
/> />
<Text style={styles.projectTitle} numberOfLines={1}> <View style={styles.projectTitleGroup}>
{displayName} <Text style={styles.projectTitle} numberOfLines={1}>
</Text> {displayName}
</Text>
<ProjectInlineChevron chevron={chevron} /> </View>
</View> </View>
{onCreateWorktree ? ( {onCreateWorktree ? (
<NewWorktreeButton <NewWorktreeButton
displayName={displayName} displayName={displayName}
onPress={onCreateWorktree} onPress={onCreateWorktree}
visible={isHovered}
testID={`sidebar-project-new-worktree-${project.projectKey}`} testID={`sidebar-project-new-worktree-${project.projectKey}`}
/> />
) : null} ) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</> </>
) )
if (menuController) { if (menuController) {
return ( return (
<ContextMenuTrigger <View
enabledOnMobile={false} onPointerEnter={() => setIsHovered(true)}
style={({ pressed, hovered = false }) => [ 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, styles.projectRow,
isDragging && styles.projectRowDragging, isDragging && styles.projectRowDragging,
selected && styles.sidebarRowSelected, selected && styles.sidebarRowSelected,
hovered && styles.projectRowHovered, isHovered && styles.projectRowHovered,
pressed && styles.projectRowPressed, pressed && styles.projectRowPressed,
]} ]}
onPressIn={interaction.handlePressIn} onPressIn={interaction.handlePressIn}
@@ -547,27 +612,8 @@ function ProjectHeaderRow({
testID={`sidebar-project-row-${project.projectKey}`} testID={`sidebar-project-row-${project.projectKey}`}
> >
{rowChildren} {rowChildren}
</ContextMenuTrigger> </Pressable>
) </View>
}
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>
) )
} }
@@ -846,6 +892,8 @@ function NonGitProjectRowWithMenuContent({
workspace, workspace,
selected, selected,
onPress, onPress,
shortcutNumber,
showShortcutBadge,
drag, drag,
isDragging, isDragging,
dragHandleProps, dragHandleProps,
@@ -856,6 +904,8 @@ function NonGitProjectRowWithMenuContent({
workspace: SidebarWorkspaceEntry workspace: SidebarWorkspaceEntry
selected: boolean selected: boolean
onPress: () => void onPress: () => void
shortcutNumber: number | null
showShortcutBadge: boolean
drag: () => void drag: () => void
isDragging: boolean isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps dragHandleProps?: DraggableListDragHandleProps
@@ -909,8 +959,10 @@ function NonGitProjectRowWithMenuContent({
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
workspace={workspace} workspace={workspace}
selected={selected} selected={selected}
chevron="disclosure" chevron={null}
onPress={onPress} onPress={onPress}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag} drag={drag}
isDragging={isDragging} isDragging={isDragging}
isArchiving={isArchivingWorkspace} isArchiving={isArchivingWorkspace}
@@ -944,6 +996,8 @@ function NonGitProjectRowWithMenu(props: {
workspace: SidebarWorkspaceEntry workspace: SidebarWorkspaceEntry
selected: boolean selected: boolean
onPress: () => void onPress: () => void
shortcutNumber: number | null
showShortcutBadge: boolean
drag: () => void drag: () => void
isDragging: boolean isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps dragHandleProps?: DraggableListDragHandleProps
@@ -955,37 +1009,65 @@ function NonGitProjectRowWithMenu(props: {
) )
} }
function WorkspaceRowPlain({ function FlattenedProjectRow({
workspace, project,
selected, displayName,
iconDataUri,
rowModel,
onPress,
onCreateWorktree,
shortcutNumber, shortcutNumber,
showShortcutBadge, showShortcutBadge,
onPress,
drag, drag,
isDragging, isDragging,
dragHandleProps, dragHandleProps,
}: { }: {
workspace: SidebarWorkspaceEntry project: SidebarProjectEntry
selected: boolean displayName: string
iconDataUri: string | null
rowModel: Extract<ReturnType<typeof buildSidebarProjectRowModel>, { kind: 'workspace_link' }>
onPress: () => void
onCreateWorktree?: () => void
shortcutNumber: number | null shortcutNumber: number | null
showShortcutBadge: boolean showShortcutBadge: boolean
onPress: () => void
drag: () => void drag: () => void
isDragging: boolean isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps 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 ( return (
<WorkspaceRowInner <ProjectHeaderRow
workspace={workspace} project={project}
selected={selected} displayName={displayName}
iconDataUri={iconDataUri}
workspace={rowModel.workspace}
selected={rowModel.selected}
chevron={rowModel.chevron}
onPress={onPress}
onCreateWorktree={rowModel.trailingAction === 'new_worktree' ? onCreateWorktree : undefined}
shortcutNumber={shortcutNumber} shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge} showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag} drag={drag}
isDragging={isDragging} isDragging={isDragging}
isArchiving={false}
dragHandleProps={dragHandleProps}
menuController={null} menuController={null}
dragHandleProps={dragHandleProps}
/> />
) )
} }
@@ -1073,11 +1155,9 @@ function ProjectBlock({
}), }),
[activeWorkspaceSelection, collapsed, project, serverId] [activeWorkspaceSelection, collapsed, project, serverId]
) )
const flattenedWorkspace = rowModel.flattenedWorkspace
const renderWorkspaceRow = useCallback( const renderWorkspaceRow = useCallback(
(item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps }) => { (item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps }) => {
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId)
const isSelected = const isSelected =
Boolean(serverId) && Boolean(serverId) &&
activeWorkspaceSelection?.serverId === serverId && activeWorkspaceSelection?.serverId === serverId &&
@@ -1105,6 +1185,7 @@ function ProjectBlock({
}, },
[ [
activeWorkspaceSelection, activeWorkspaceSelection,
project.projectKind,
onWorkspacePress, onWorkspacePress,
serverId, serverId,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
@@ -1137,20 +1218,26 @@ function ProjectBlock({
return ( return (
<View style={styles.projectBlock}> <View style={styles.projectBlock}>
{flattenedWorkspace ? ( {rowModel.kind === 'workspace_link' ? (
<NonGitProjectRowWithMenu <FlattenedProjectRow
project={project} project={project}
displayName={displayName} displayName={displayName}
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
workspace={flattenedWorkspace} rowModel={rowModel}
selected={rowModel.selected}
onPress={() => { onPress={() => {
if (!serverId) { if (!serverId) {
return return
} }
onWorkspacePress?.() 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} drag={drag}
isDragging={isDragging} isDragging={isDragging}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
@@ -1201,6 +1288,7 @@ export function SidebarWorkspaceList({
serverId, serverId,
collapsedProjectKeys, collapsedProjectKeys,
onToggleProjectCollapsed, onToggleProjectCollapsed,
onSetProjectCollapsed,
shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey,
isRefreshing = false, isRefreshing = false,
onRefresh, onRefresh,
@@ -1353,6 +1441,7 @@ export function SidebarWorkspaceList({
if (!serverId || project.projectKind !== 'git') { if (!serverId || project.projectKind !== 'git') {
return return
} }
onSetProjectCollapsed(project.projectKey, false)
onWorkspacePress?.() onWorkspacePress?.()
router.push( router.push(
buildHostNewAgentRoute(serverId, { buildHostNewAgentRoute(serverId, {
@@ -1361,7 +1450,7 @@ export function SidebarWorkspaceList({
}) as any }) as any
) )
}, },
[serverId, onWorkspacePress] [onSetProjectCollapsed, serverId, onWorkspacePress]
) )
const renderProject = useCallback( const renderProject = useCallback(
@@ -1519,6 +1608,13 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
projectTitleGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
projectIcon: { projectIcon: {
width: '100%', width: '100%',
height: '100%', height: '100%',
@@ -1547,8 +1643,8 @@ const styles = StyleSheet.create((theme) => ({
projectTitle: { projectTitle: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
flex: 1,
minWidth: 0, minWidth: 0,
flexShrink: 1,
}, },
projectActionButton: { projectActionButton: {
flexDirection: 'row', flexDirection: 'row',
@@ -1577,6 +1673,16 @@ const styles = StyleSheet.create((theme) => ({
projectIconActionButtonHovered: { projectIconActionButtonHovered: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
projectIconActionButtonHidden: {
opacity: 0,
},
projectTrailingControlSlot: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
projectActionTooltipText: { projectActionTooltipText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -1698,11 +1804,16 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
flexShrink: 0,
}, },
shortcutBadgeText: { shortcutBadgeText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.medium,
lineHeight: 14, lineHeight: 14,
}, },
})) }))
@@ -243,9 +243,79 @@ export function ToolCallDetailsContent({
); );
} }
} else if (detail?.type === "search") { } 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( sections.push(
<View key="search" style={styles.section}> <View
<Text selectable style={styles.scrollText}>{detail.query}</Text> 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> </View>
); );
} else if (detail?.type === "plain_text") { } 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 type { SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts' import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
import { isSidebarProjectFlattened } from '@/utils/sidebar-project-row-model'
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set()) const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
@@ -23,10 +24,14 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
useEffect(() => { useEffect(() => {
setCollapsedProjectKeys((prev) => { 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>() const next = new Set<string>()
for (const key of prev) { for (const key of prev) {
if (validProjectKeys.has(key)) { if (collapsibleProjectKeys.has(key)) {
next.add(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 { return {
collapsedProjectKeys, collapsedProjectKeys,
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey, shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
setProjectCollapsed,
toggleProjectCollapsed, toggleProjectCollapsed,
} }
} }
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { buildSidebarProjectRowModel } from './sidebar-project-row-model' import {
buildSidebarProjectRowModel,
isSidebarProjectFlattened,
} from './sidebar-project-row-model'
import type { import type {
SidebarProjectEntry, SidebarProjectEntry,
SidebarWorkspaceEntry, SidebarWorkspaceEntry,
@@ -51,11 +54,11 @@ describe('buildSidebarProjectRowModel', () => {
}) })
expect(result).toEqual({ expect(result).toEqual({
interaction: 'navigate', kind: 'workspace_link',
chevron: 'disclosure', workspace: flattenedWorkspace,
trailingAction: 'none',
flattenedWorkspace,
selected: false, 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({ const result = buildSidebarProjectRowModel({
project: project({ project: project({
projectKind: 'git', projectKind: 'git',
@@ -94,11 +123,28 @@ describe('buildSidebarProjectRowModel', () => {
}) })
expect(result).toEqual({ expect(result).toEqual({
interaction: 'toggle', kind: 'project_section',
chevron: 'expand', chevron: 'expand',
trailingAction: 'new_worktree', 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, SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-workspaces-list' } from '@/hooks/use-sidebar-workspaces-list'
export interface SidebarProjectRowModel { export interface SidebarProjectWorkspaceLinkRowModel {
interaction: 'toggle' | 'navigate' kind: 'workspace_link'
chevron: 'expand' | 'collapse' | 'disclosure' workspace: SidebarWorkspaceEntry
trailingAction: 'new_worktree' | 'none'
flattenedWorkspace: SidebarWorkspaceEntry | null
selected: boolean 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: { export function buildSidebarProjectRowModel(input: {
@@ -17,10 +31,9 @@ export function buildSidebarProjectRowModel(input: {
serverId?: string | null serverId?: string | null
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null
}): SidebarProjectRowModel { }): SidebarProjectRowModel {
const flattenedWorkspace = const flattenedWorkspace = isSidebarProjectFlattened(input.project)
input.project.projectKind === 'non_git' && input.project.workspaces.length === 1 ? (input.project.workspaces[0] ?? null)
? input.project.workspaces[0] ?? null : null
: null
const selected = const selected =
flattenedWorkspace !== null && flattenedWorkspace !== null &&
Boolean(input.serverId) && Boolean(input.serverId) &&
@@ -29,19 +42,17 @@ export function buildSidebarProjectRowModel(input: {
if (flattenedWorkspace) { if (flattenedWorkspace) {
return { return {
interaction: 'navigate', kind: 'workspace_link',
chevron: 'disclosure', workspace: flattenedWorkspace,
trailingAction: 'none',
flattenedWorkspace,
selected, selected,
chevron: null,
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
} }
} }
return { return {
interaction: 'toggle', kind: 'project_section',
chevron: input.collapsed ? 'expand' : 'collapse', chevron: input.collapsed ? 'expand' : 'collapse',
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none', 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", () => { it("builds shortcut targets in visual order and excludes collapsed projects", () => {
const projects = [ const projects = [
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]), 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({ const model = buildSidebarShortcutModel({
@@ -70,4 +70,16 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" }); expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" }); 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, SidebarProjectEntry,
SidebarWorkspaceEntry, SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-workspaces-list' } from '@/hooks/use-sidebar-workspaces-list'
import { isSidebarProjectFlattened } from './sidebar-project-row-model'
export interface SidebarShortcutWorkspaceTarget { export interface SidebarShortcutWorkspaceTarget {
serverId: string serverId: string
@@ -34,7 +35,7 @@ export function buildSidebarShortcutModel(input: {
const shortcutIndexByWorkspaceKey = new Map<string, number>() const shortcutIndexByWorkspaceKey = new Map<string, number>()
for (const project of input.projects) { for (const project of input.projects) {
if (input.collapsedProjectKeys.has(project.projectKey)) { if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) {
continue continue
} }
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const tauriMock = vi.hoisted(() => { const tauriMock = vi.hoisted(() => {
let listenerCleanup: (() => void) | null = null; let listenerCleanup: (() => void) | null = null;
let disconnectCount = 0;
const connect = vi.fn(async () => { const connect = vi.fn(async () => {
let listenerActive = false; let listenerActive = false;
@@ -21,6 +22,7 @@ const tauriMock = vi.hoisted(() => {
}), }),
send: vi.fn(async () => undefined), send: vi.fn(async () => undefined),
disconnect: vi.fn(async () => { disconnect: vi.fn(async () => {
disconnectCount += 1;
listenerCleanup?.(); listenerCleanup?.();
}), }),
}; };
@@ -29,6 +31,11 @@ const tauriMock = vi.hoisted(() => {
return { return {
connect, connect,
getListenerCleanup: () => listenerCleanup, getListenerCleanup: () => listenerCleanup,
getDisconnectCount: () => disconnectCount,
resetState: () => {
listenerCleanup = null;
disconnectCount = 0;
},
}; };
}); });
@@ -43,6 +50,7 @@ vi.mock("./tauri", () => ({
describe("tauri-daemon-transport", () => { describe("tauri-daemon-transport", () => {
beforeEach(() => { beforeEach(() => {
tauriMock.connect.mockClear(); tauriMock.connect.mockClear();
tauriMock.resetState();
}); });
it("does not unregister the websocket listener twice during close", async () => { it("does not unregister the websocket listener twice during close", async () => {
@@ -54,6 +62,82 @@ describe("tauri-daemon-transport", () => {
await Promise.resolve(); await Promise.resolve();
expect(() => transport.close()).not.toThrow(); expect(() => transport.close()).not.toThrow();
await Promise.resolve();
expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1); 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>; connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
}; };
function isTauriDuplicateListenerCleanupError(error: unknown): boolean {
return (
error instanceof TypeError &&
error.message.includes("listeners[eventId].handlerId")
);
}
function toTauriOutgoingMessage( function toTauriOutgoingMessage(
data: string | Uint8Array | ArrayBuffer data: string | Uint8Array | ArrayBuffer
): string | number[] { ): string | number[] {
@@ -49,6 +56,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
let disposed = false; let disposed = false;
let opened = false; let opened = false;
let closeEmitted = false; let closeEmitted = false;
let disconnectPromise: Promise<void> | null = null;
const openHandlers = new Set<() => void>(); const openHandlers = new Set<() => void>();
const closeHandlers = new Set<(event?: unknown) => 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 () => { const connect = async () => {
try { try {
let module: TauriWebSocketModule | null = getTauriWebSocketModule(); let module: TauriWebSocketModule | null = getTauriWebSocketModule();
@@ -122,11 +145,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
const socket = await module.connect(url); const socket = await module.connect(url);
if (disposed) { if (disposed) {
try { await disconnectSocket(socket);
await socket.disconnect();
} catch {
// no-op
}
return; return;
} }
@@ -160,13 +179,8 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
} }
if (closeRequested) { if (closeRequested) {
try { await disconnectSocket(ws);
await ws.disconnect(); emitClose(closeRequested);
} catch (error) {
emitError(error);
} finally {
emitClose(closeRequested);
}
} }
} catch (error) { } catch (error) {
emitError(error); emitError(error);
@@ -189,10 +203,7 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
if (disposed) return; if (disposed) return;
closeRequested = { code, reason }; closeRequested = { code, reason };
if (!ws) return; if (!ws) return;
void ws void disconnectSocket(ws).finally(() => emitClose(closeRequested));
.disconnect()
.catch((error) => emitError(error))
.finally(() => emitClose(closeRequested));
}, },
onMessage: (handler) => { onMessage: (handler) => {
messageHandlers.add(handler); messageHandlers.add(handler);
@@ -230,7 +241,13 @@ export function createTauriWebSocketTransportFactory(): DaemonTransportFactory |
...transport, ...transport,
close: (code?: number, reason?: string) => { close: (code?: number, reason?: string) => {
wsListenerCleanup = null; wsListenerCleanup = null;
transport.close(code, reason); try {
transport.close(code, reason);
} catch (error) {
if (!isTauriDuplicateListenerCleanupError(error)) {
throw error;
}
}
disposed = true; disposed = true;
ws = null; ws = null;
}, },
@@ -57,4 +57,26 @@ describe("tool-call detail state", () => {
false 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": case "write":
return Boolean(detail.filePath || detail.content); return Boolean(detail.filePath || detail.content);
case "search": 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": case "worktree_setup":
return Boolean(detail.branchName || detail.worktreePath || detail.log); return Boolean(detail.branchName || detail.worktreePath || detail.log);
case "sub_agent": case "sub_agent":
+1
View File
@@ -20,6 +20,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
edit: Pencil, edit: Pencil,
write: Pencil, write: Pencil,
search: Search, search: Search,
fetch: Search,
worktree_setup: SquareTerminal, worktree_setup: SquareTerminal,
sub_agent: Bot, sub_agent: Bot,
plain_text: Wrench, plain_text: Wrench,
+1 -1
View File
@@ -2629,7 +2629,7 @@ dependencies = [
[[package]] [[package]]
name = "paseo" name = "paseo"
version = "0.1.29" version = "0.1.30"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"dirs", "dirs",
@@ -147,6 +147,30 @@ export type ToolCallDetail =
| { | {
type: "search"; type: "search";
query: string; 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"; type: "worktree_setup";
@@ -7,11 +7,17 @@ import {
ToolReadInputSchema, ToolReadInputSchema,
ToolReadOutputSchema, ToolReadOutputSchema,
ToolSearchInputSchema, ToolSearchInputSchema,
ToolGrepOutputSchema,
ToolGlobOutputSchema,
ToolShellInputSchema, ToolShellInputSchema,
ToolShellOutputSchema, ToolShellOutputSchema,
ToolWebFetchInputSchema,
ToolWebFetchOutputSchema,
ToolWebSearchOutputSchema,
ToolWriteInputSchema, ToolWriteInputSchema,
ToolWriteOutputSchema, ToolWriteOutputSchema,
toEditToolDetail, toEditToolDetail,
toFetchToolDetail,
toReadToolDetail, toReadToolDetail,
toSearchToolDetail, toSearchToolDetail,
toShellToolDetail, toShellToolDetail,
@@ -87,27 +93,32 @@ const ClaudeToolDetailPass2Schema = z.union([
ToolEditOutputSchema, ToolEditOutputSchema,
toEditToolDetail toEditToolDetail
), ),
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByName("WebSearch", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
toSearchToolDetail(input) toSearchToolDetail({ input, output, toolName: "web_search" })
), ),
toolDetailBranchByName("web_search", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByName("web_search", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
toSearchToolDetail(input) toSearchToolDetail({ input, output, toolName: "web_search" })
), ),
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input) toSearchToolDetail({ input, toolName: "search" })
), ),
toolDetailBranchByName("Grep", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByName("Grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
toSearchToolDetail(input) toSearchToolDetail({ input, output, toolName: "grep" })
), ),
toolDetailBranchByName("grep", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByName("grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
toSearchToolDetail(input) toSearchToolDetail({ input, output, toolName: "grep" })
), ),
toolDetailBranchByName("Glob", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByName("Glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
toSearchToolDetail(input) toSearchToolDetail({ input, output, toolName: "glob" })
), ),
toolDetailBranchByName("glob", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByName("glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
toSearchToolDetail(input) 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( toolDetailBranchByName(
"Skill", "Skill",
z.object({ skill: z.string() }).passthrough(), z.object({ skill: z.string() }).passthrough(),
@@ -103,6 +103,7 @@ describe("claude tool-call mapper", () => {
expect(searchItem.detail).toEqual({ expect(searchItem.detail).toEqual({
type: "search", type: "search",
query: "tool call mapping", query: "tool call mapping",
toolName: "web_search",
}); });
}); });
@@ -220,6 +221,7 @@ describe("claude tool-call mapper", () => {
expect(searchItem.detail).toEqual({ expect(searchItem.detail).toEqual({
type: "search", type: "search",
query: "tool call mapping", query: "tool call mapping",
toolName: "web_search",
}); });
}); });
@@ -248,7 +250,12 @@ describe("claude tool-call mapper", () => {
callId: "claude-glob-1", callId: "claude-glob-1",
name: "Glob", name: "Glob",
input: { pattern: "**/.claude/commands/paseo*" }, 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({ expect(item.detail).toEqual({
type: "search", type: "search",
query: "**/.claude/commands/paseo*", 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", output_mode: "content",
"-n": true, "-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({ expect(item.detail).toEqual({
type: "search", type: "search",
query: '\\\\\\"cli\\\\\\""', 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"),
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 const ClaudeSpeakToolNameSchema = z
.string() .string()
.min(1) .min(1)
@@ -91,6 +98,7 @@ const ClaudeToolKindSchema = z.enum([
"write", "write",
"edit", "edit",
"search", "search",
"fetch",
"speak", "speak",
"unknown", "unknown",
]); ]);
@@ -146,6 +154,13 @@ const ClaudeToolCallPass2EnvelopeSchema = z.union([
name: normalized.name.trim(), name: normalized.name.trim(),
toolKind: "search" as const, toolKind: "search" as const,
})), })),
ClaudeToolCallPass2InputSchema.extend({
name: ClaudeFetchToolNameSchema,
}).transform((normalized) => ({
...normalized,
name: normalized.name.trim(),
toolKind: "fetch" as const,
})),
ClaudeToolCallPass2InputSchema.extend({ ClaudeToolCallPass2InputSchema.extend({
name: ClaudeSpeakToolNameSchema, name: ClaudeSpeakToolNameSchema,
}).transform((normalized) => ({ }).transform((normalized) => ({
@@ -181,6 +196,10 @@ const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
toolKind: z.literal("search"), toolKind: z.literal("search"),
name: ClaudeSearchToolNameSchema, name: ClaudeSearchToolNameSchema,
}), }),
ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("fetch"),
name: ClaudeFetchToolNameSchema,
}),
ClaudeToolCallPass2BaseSchema.extend({ ClaudeToolCallPass2BaseSchema.extend({
toolKind: z.literal("speak"), toolKind: z.literal("speak"),
name: z.literal("speak"), name: z.literal("speak"),
@@ -203,8 +222,8 @@ function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimeli
? "write_file" ? "write_file"
: normalized.toolKind === "edit" : normalized.toolKind === "edit"
? "apply_patch" ? "apply_patch"
: normalized.toolKind === "search" : normalized.toolKind === "search" || normalized.toolKind === "fetch"
? "search" ? normalized.name
: normalized.toolKind === "speak" : normalized.toolKind === "speak"
? "speak" ? "speak"
: normalized.name; : normalized.name;
@@ -135,6 +135,10 @@ function normalizeCodexModelId(modelId: string | null | undefined): string | und
return normalized; return normalized;
} }
function normalizeCodexModelLabel(displayName: string): string {
return displayName.replace(/\bgpt\b/gi, 'GPT');
}
type CodexConfiguredDefaults = { type CodexConfiguredDefaults = {
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
@@ -3389,7 +3393,7 @@ export class CodexAppServerAgentClient implements AgentClient {
return { return {
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
id: model.id, id: model.id,
label: model.displayName, label: normalizeCodexModelLabel(model.displayName),
description: model.description, description: model.description,
isDefault: isDefaultModel, isDefault: isDefaultModel,
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined, thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
@@ -150,10 +150,10 @@ const CodexToolDetailPass2Schema = z.union([
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) }) (input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
), ),
toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input) toSearchToolDetail({ input, toolName: "search" })
), ),
toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input) toSearchToolDetail({ input, toolName: "web_search" })
), ),
CodexSpeakToolDetailSchema, CodexSpeakToolDetailSchema,
]); ]);
@@ -151,6 +151,7 @@ describe("codex tool-call mapper", () => {
expect(searchItem?.detail).toEqual({ expect(searchItem?.detail).toEqual({
type: "search", type: "search",
query: "codex timeline", query: "codex timeline",
toolName: "web_search",
}); });
}); });
@@ -338,6 +339,7 @@ describe("codex tool-call mapper", () => {
expect(searchItem?.detail).toEqual({ expect(searchItem?.detail).toEqual({
type: "search", type: "search",
query: "codex timeline", query: "codex timeline",
toolName: "web_search",
}); });
}); });
@@ -38,10 +38,10 @@ const OpencodeKnownToolDetailSchema = z.union([
toolDetailBranchByToolName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail), toolDetailBranchByToolName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByToolName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail), toolDetailBranchByToolName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) => toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) =>
toSearchToolDetail(input) toSearchToolDetail({ input, toolName: "search" })
), ),
toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) => 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({ expect(searchItem.detail).toEqual({
type: "search", type: "search",
query: "opencode mapper", query: "opencode mapper",
toolName: "web_search",
}); });
}); });
@@ -210,6 +211,7 @@ describe("opencode tool-call mapper", () => {
expect(searchItem.detail).toEqual({ expect(searchItem.detail).toEqual({
type: "search", type: "search",
query: "opencode mapper", 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 })), 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 ParsedToolShellInput = z.infer<typeof ToolShellInputSchema>;
export type ParsedToolShellOutput = z.infer<typeof ToolShellOutputSchema>; export type ParsedToolShellOutput = z.infer<typeof ToolShellOutputSchema>;
export type ParsedToolReadInput = z.infer<typeof ToolReadInputSchema>; 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 ParsedToolEditInput = z.infer<typeof ToolEditInputSchema>;
export type ParsedToolEditOutput = z.infer<typeof ToolEditOutputSchema>; export type ParsedToolEditOutput = z.infer<typeof ToolEditOutputSchema>;
export type ParsedToolSearchInput = z.infer<typeof ToolSearchInputSchema>; 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; type NormalizePathFn = (filePath: string) => string | undefined;
@@ -539,6 +609,24 @@ function normalizeDetailPath(
return normalizePath ? normalizePath(trimmed) : trimmed; 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( export function toShellToolDetail(
input: ParsedToolShellInput | null, input: ParsedToolShellInput | null,
output: ParsedToolShellOutput | null output: ParsedToolShellOutput | null
@@ -625,15 +713,70 @@ export function toEditToolDetail(
}; };
} }
export function toSearchToolDetail( export function toSearchToolDetail(params: {
input: ParsedToolSearchInput | null input: ParsedToolSearchInput | null;
): ToolCallDetail | undefined { output?: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null;
toolName?: "search" | "grep" | "glob" | "web_search";
}): ToolCallDetail | undefined {
const { input, output, toolName } = params;
if (!input?.query) { if (!input?.query) {
return undefined; 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 { return {
type: "search", type: "search",
query: input.query, 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({ z.object({
type: z.literal('search'), type: z.literal('search'),
query: z.string(), 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({ z.object({
type: z.literal('worktree_setup'), type: z.literal('worktree_setup'),
@@ -90,6 +90,11 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay
displayName: "Search", displayName: "Search",
summary: input.detail.query, summary: input.detail.query,
}; };
case "fetch":
return {
displayName: "Fetch",
summary: input.detail.url,
};
case "worktree_setup": case "worktree_setup":
return { return {
displayName: "Worktree Setup", displayName: "Worktree Setup",