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 { 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,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":
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Generated
+1
-1
@@ -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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user