Add fetch tool details and improve sidebar shortcuts handling
This commit is contained in:
@@ -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,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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
-1
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user