refactor: extract explorer open gesture and standardize icon sizes

This commit is contained in:
Mohamed Boudra
2026-02-19 09:53:44 +07:00
parent 08d4cfac11
commit 24a8db5763
25 changed files with 659 additions and 237 deletions
+23
View File
@@ -52,6 +52,17 @@ import {
import { getTauri } from "@/utils/tauri"; import { getTauri } from "@/utils/tauri";
polyfillCrypto(); polyfillCrypto();
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logLeftSidebarOpenGesture(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[LeftSidebarOpenGesture] ${event}`, details);
}
function PushNotificationRouter() { function PushNotificationRouter() {
const router = useRouter(); const router = useRouter();
@@ -212,6 +223,10 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
}) })
.onStart(() => { .onStart(() => {
isGesturing.value = true; isGesturing.value = true;
runOnJS(logLeftSidebarOpenGesture)("start", {
mobileView,
openGestureEnabled,
});
}) })
.onUpdate((event) => { .onUpdate((event) => {
// Start from closed position (-windowWidth) and move towards 0 // Start from closed position (-windowWidth) and move towards 0
@@ -228,6 +243,13 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
isGesturing.value = false; isGesturing.value = false;
// Open if dragged more than 1/3 of sidebar or fast swipe // Open if dragged more than 1/3 of sidebar or fast swipe
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logLeftSidebarOpenGesture)("end", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldOpen,
mobileView,
openGestureEnabled,
});
if (shouldOpen) { if (shouldOpen) {
animateToOpen(); animateToOpen();
runOnJS(openAgentList)(); runOnJS(openAgentList)();
@@ -246,6 +268,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
animateToOpen, animateToOpen,
animateToClose, animateToClose,
openAgentList, openAgentList,
mobileView,
isGesturing, isGesturing,
horizontalScroll?.isAnyScrolledRight, horizontalScroll?.isAnyScrolledRight,
touchStartX, touchStartX,
@@ -97,7 +97,7 @@ export function DropdownField({
> >
{value || placeholder} {value || placeholder}
</Text> </Text>
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} /> <ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable> </Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null} {warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
@@ -196,7 +196,7 @@ export function SelectField({
{value || placeholder || "Select..."} {value || placeholder || "Select..."}
</Text> </Text>
</View> </View>
<ChevronRight size={20} color={defaultTheme.colors.foregroundMuted} /> <ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} />
</Pressable> </Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null} {errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null} {warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
@@ -289,7 +289,7 @@ export function DropdownSheet({
hitSlop={10} hitSlop={10}
testID="dropdown-sheet-close" testID="dropdown-sheet-close"
> >
<X size={18} color={defaultTheme.colors.foregroundMuted} /> <X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable> </Pressable>
</View> </View>
<BottomSheetScrollView <BottomSheetScrollView
@@ -477,7 +477,7 @@ export function FormSelectTrigger({
</Text> </Text>
)} )}
</View> </View>
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} /> <ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable> </Pressable>
); );
} }
@@ -571,7 +571,7 @@ export function AgentConfigRow({
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"} placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
disabled={disabled || providerOptions.length === 0} disabled={disabled || providerOptions.length === 0}
onSelect={onSelectProvider} onSelect={onSelectProvider}
icon={<Bot size={16} color={defaultTheme.colors.foregroundMuted} />} icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
/> />
</View> </View>
@@ -585,7 +585,7 @@ export function AgentConfigRow({
disabled={disabled} disabled={disabled}
isLoading={isModelLoading} isLoading={isModelLoading}
onSelect={onSelectModel} onSelect={onSelectModel}
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />} icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
/> />
</View> </View>
@@ -598,7 +598,7 @@ export function AgentConfigRow({
placeholder="Default" placeholder="Default"
disabled={disabled || modeOptions.length === 0} disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode} onSelect={onSelectMode}
icon={<Shield size={16} color={defaultTheme.colors.foregroundMuted} />} icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
/> />
</View> </View>
@@ -612,7 +612,7 @@ export function AgentConfigRow({
placeholder="Select..." placeholder="Select..."
disabled={disabled} disabled={disabled}
onSelect={onSelectThinkingOption} onSelect={onSelectThinkingOption}
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />} icon={<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
/> />
</View> </View>
@@ -1120,16 +1120,16 @@ export function GitOptionsSection({
onSubmitEditing={handleConfirmEdit} onSubmitEditing={handleConfirmEdit}
/> />
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}> <Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<Check size={16} color={defaultTheme.colors.palette.green[500]} /> <Check size={defaultTheme.iconSize.md} color={defaultTheme.colors.palette.green[500]} />
</Pressable> </Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}> <Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={16} color={defaultTheme.colors.foregroundMuted} /> <X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
</Pressable> </Pressable>
</View> </View>
) : ( ) : (
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}> <Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
<Text style={styles.baseBranchValue}>{displayBranch}</Text> <Text style={styles.baseBranchValue}>{displayBranch}</Text>
<Pencil size={14} color={defaultTheme.colors.foregroundMuted} /> <Pencil size={defaultTheme.iconSize.sm} color={defaultTheme.colors.foregroundMuted} />
</Pressable> </Pressable>
)} )}
</View> </View>
@@ -562,7 +562,7 @@ export function AgentInputArea({
{isCancellingAgent ? ( {isCancellingAgent ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : (
<Square size={18} color="white" fill="white" /> <Square size={theme.iconSize.lg} color="white" fill="white" />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -591,7 +591,7 @@ export function AgentInputArea({
{voice?.isVoiceSwitching ? ( {voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : (
<AudioLines size={18} color={theme.colors.foreground} /> <AudioLines size={theme.iconSize.lg} color={theme.colors.foreground} />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -628,13 +628,13 @@ export function AgentInputArea({
onPress={() => handleEditQueuedMessage(item.id)} onPress={() => handleEditQueuedMessage(item.id)}
style={styles.queueActionButton} style={styles.queueActionButton}
> >
<Pencil size={14} color={theme.colors.foreground} /> <Pencil size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable> </Pressable>
<Pressable <Pressable
onPress={() => handleSendQueuedNow(item.id)} onPress={() => handleSendQueuedNow(item.id)}
style={[styles.queueActionButton, styles.queueSendButton]} style={[styles.queueActionButton, styles.queueSendButton]}
> >
<ArrowUp size={14} color="white" /> <ArrowUp size={theme.iconSize.sm} color="white" />
</Pressable> </Pressable>
</View> </View>
</View> </View>
@@ -118,7 +118,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
agent.currentModeId || agent.currentModeId ||
"default"} "default"}
</Text> </Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
@@ -157,7 +157,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-model-selector" testID="agent-model-selector"
> >
<Text style={styles.modeBadgeText}>{displayModel}</Text> <Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
@@ -199,9 +199,13 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
accessibilityLabel="Select thinking option" accessibilityLabel="Select thinking option"
testID="agent-thinking-selector" testID="agent-thinking-selector"
> >
<Brain size={12} color={theme.colors.foregroundMuted} style={{ marginTop: 1 }} /> <Brain
size={theme.iconSize.xs}
color={theme.colors.foregroundMuted}
style={{ marginTop: 1 }}
/>
<Text style={styles.modeBadgeText}>{displayThinking}</Text> <Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"
@@ -249,7 +253,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
accessibilityLabel="Agent preferences" accessibilityLabel="Agent preferences"
testID="agent-preferences-button" testID="agent-preferences-button"
> >
<SlidersHorizontal size={20} color={theme.colors.foreground} /> <SlidersHorizontal size={theme.iconSize.lg} color={theme.colors.foreground} />
</Pressable> </Pressable>
<AdaptiveModalSheet <AdaptiveModalSheet
@@ -271,7 +275,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-preferences-mode" testID="agent-preferences-mode"
> >
<Text style={styles.sheetSelectText}>{displayMode}</Text> <Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start"> <DropdownMenuContent side="top" align="start">
{agent.availableModes.map((mode) => { {agent.availableModes.map((mode) => {
@@ -303,7 +307,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-preferences-model" testID="agent-preferences-model"
> >
<Text style={styles.sheetSelectText}>{displayModel}</Text> <Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start"> <DropdownMenuContent side="top" align="start">
{models?.map((model) => { {models?.map((model) => {
@@ -342,7 +346,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
testID="agent-preferences-thinking" testID="agent-preferences-thinking"
> >
<Text style={styles.sheetSelectText}>{displayThinking}</Text> <Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start"> <DropdownMenuContent side="top" align="start">
{thinkingOptions.map((opt) => { {thinkingOptions.map((opt) => {
@@ -58,7 +58,7 @@ export function DictationControls({
accessibilityLabel="Start voice dictation" accessibilityLabel="Start voice dictation"
style={[styles.micButton, disabled && styles.buttonDisabled]} style={[styles.micButton, disabled && styles.buttonDisabled]}
> >
<Mic size={16} color={theme.colors.foreground} /> <Mic size={theme.iconSize.md} color={theme.colors.foreground} />
</Pressable> </Pressable>
); );
} }
@@ -88,7 +88,7 @@ export function DictationControls({
actionsDisabled && !isFailed ? styles.buttonDisabled : undefined, actionsDisabled && !isFailed ? styles.buttonDisabled : undefined,
]} ]}
> >
<X size={14} color={theme.colors.foreground} /> <X size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable> </Pressable>
{actionsDisabled ? ( {actionsDisabled ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
@@ -100,7 +100,7 @@ export function DictationControls({
accessibilityLabel="Retry dictation" accessibilityLabel="Retry dictation"
style={[styles.actionButton, styles.actionButtonConfirm]} style={[styles.actionButton, styles.actionButtonConfirm]}
> >
<RefreshCcw size={14} color={theme.colors.surface0} /> <RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable> </Pressable>
) : ( ) : (
<> <>
@@ -109,14 +109,14 @@ export function DictationControls({
accessibilityLabel="Insert transcription" accessibilityLabel="Insert transcription"
style={[styles.actionButton, styles.actionButtonSecondary]} style={[styles.actionButton, styles.actionButtonSecondary]}
> >
<Check size={14} color={theme.colors.foreground} /> <Check size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable> </Pressable>
<Pressable <Pressable
onPress={onAcceptAndSend} onPress={onAcceptAndSend}
accessibilityLabel="Insert transcription and send" accessibilityLabel="Insert transcription and send"
style={[styles.actionButton, styles.actionButtonConfirm]} style={[styles.actionButton, styles.actionButtonConfirm]}
> >
<ArrowUp size={14} color={theme.colors.surface0} /> <ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable> </Pressable>
</> </>
)} )}
@@ -167,7 +167,7 @@ export function DictationOverlay({
actionsDisabled && !isFailed && overlayStyles.buttonDisabled, actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
]} ]}
> >
<X size={20} color={theme.colors.palette.white} strokeWidth={2.5} /> <X size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
</Pressable> </Pressable>
<View style={overlayStyles.centerContainer}> <View style={overlayStyles.centerContainer}>
@@ -219,7 +219,7 @@ export function DictationOverlay({
]} ]}
> >
<RefreshCcw <RefreshCcw
size={20} size={theme.iconSize.lg}
color={theme.colors.accent} color={theme.colors.accent}
strokeWidth={2.5} strokeWidth={2.5}
/> />
@@ -234,7 +234,7 @@ export function DictationOverlay({
]} ]}
> >
<Pencil <Pencil
size={20} size={theme.iconSize.lg}
color={theme.colors.palette.white} color={theme.colors.palette.white}
strokeWidth={2.5} strokeWidth={2.5}
/> />
@@ -247,7 +247,7 @@ export function DictationOverlay({
]} ]}
> >
<ArrowUp <ArrowUp
size={20} size={theme.iconSize.lg}
color={theme.colors.accent} color={theme.colors.accent}
strokeWidth={2.5} strokeWidth={2.5}
/> />
@@ -24,6 +24,14 @@ import { TerminalPane } from "./terminal-pane";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerSidebar(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return;
}
console.log(`[ExplorerSidebar] ${event}`, details);
}
function resolveKeyboardShift(rawHeight: number, inset: number): number { function resolveKeyboardShift(rawHeight: number, inset: number): number {
"worklet"; "worklet";
@@ -94,9 +102,18 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
const startWidthRef = useRef(explorerWidth); const startWidthRef = useRef(explorerWidth);
const resizeWidth = useSharedValue(explorerWidth); const resizeWidth = useSharedValue(explorerWidth);
const handleClose = useCallback(() => { const handleClose = useCallback(
closeToAgent(); (reason: string) => {
}, [closeToAgent]); logExplorerSidebar("handleClose", {
reason,
isOpen,
mobileView,
desktopFileExplorerOpen,
});
closeToAgent();
},
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView]
);
const enableSidebarCloseGesture = isMobile && isOpen; const enableSidebarCloseGesture = isMobile && isOpen;
@@ -165,9 +182,15 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
isGesturing.value = false; isGesturing.value = false;
const shouldClose = const shouldClose =
event.translationX > windowWidth / 3 || event.velocityX > 500; event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldClose,
windowWidth,
});
if (shouldClose) { if (shouldClose) {
animateToClose(); animateToClose();
runOnJS(handleClose)(); runOnJS(handleClose)("swipe-close-gesture");
} else { } else {
animateToOpen(); animateToOpen();
} }
@@ -249,7 +272,10 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}> <View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */} {/* Backdrop */}
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}> <Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
<Pressable style={styles.backdropPressable} onPress={handleClose} /> <Pressable
style={styles.backdropPressable}
onPress={() => handleClose("backdrop-press")}
/>
</Animated.View> </Animated.View>
<GestureDetector gesture={closeGesture} touchAction="pan-y"> <GestureDetector gesture={closeGesture} touchAction="pan-y">
@@ -265,7 +291,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
<SidebarContent <SidebarContent
activeTab={explorerTab} activeTab={explorerTab}
onTabPress={handleTabPress} onTabPress={handleTabPress}
onClose={handleClose} onClose={() => handleClose("header-close-button")}
serverId={serverId} serverId={serverId}
agentId={agentId} agentId={agentId}
cwd={cwd} cwd={cwd}
@@ -298,7 +324,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
<SidebarContent <SidebarContent
activeTab={explorerTab} activeTab={explorerTab}
onTabPress={handleTabPress} onTabPress={handleTabPress}
onClose={handleClose} onClose={() => handleClose("desktop-close-button")}
serverId={serverId} serverId={serverId}
agentId={agentId} agentId={agentId}
cwd={cwd} cwd={cwd}
@@ -26,7 +26,7 @@ export function BackHeader({
onPress={onBack ?? (() => router.back())} onPress={onBack ?? (() => router.back())}
style={styles.backButton} style={styles.backButton}
> >
<ArrowLeft size={20} color={theme.colors.foregroundMuted} /> <ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
{title && ( {title && (
<Text style={styles.title} numberOfLines={1}> <Text style={styles.title} numberOfLines={1}>
@@ -75,7 +75,7 @@ export function SidebarMenuToggle({
{isMobile ? ( {isMobile ? (
<MobileMenuIcon color={menuIconColor} /> <MobileMenuIcon color={menuIconColor} />
) : ( ) : (
<PanelLeft size={16} color={menuIconColor} /> <PanelLeft size={theme.iconSize.md} color={menuIconColor} />
)} )}
</HeaderToggleButton> </HeaderToggleButton>
); );
@@ -645,7 +645,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(hovered || !IS_WEB) && styles.removeImageButtonVisible, (hovered || !IS_WEB) && styles.removeImageButtonVisible,
]} ]}
> >
<X size={16} color="white" /> <X size={theme.iconSize.md} color="white" />
</View> </View>
)} )}
</> </>
@@ -703,7 +703,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
accessibilityRole="button" accessibilityRole="button"
style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]} style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]}
> >
<Paperclip size={20} color={theme.colors.foreground} /> <Paperclip size={theme.iconSize.lg} color={theme.colors.foreground} />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text> <Text style={styles.tooltipText}>Attach images</Text>
@@ -736,11 +736,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]} ]}
> >
{isDictating ? ( {isDictating ? (
<Square size={14} color="white" fill="white" /> <Square size={theme.iconSize.lg} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={20} color={theme.colors.foreground} /> <MicOff size={theme.iconSize.lg} color={theme.colors.foreground} />
) : ( ) : (
<Mic size={20} color={theme.colors.foreground} /> <Mic size={theme.iconSize.lg} color={theme.colors.foreground} />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -769,7 +769,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
accessibilityRole="button" accessibilityRole="button"
style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]} style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]}
> >
<Plus size={20} color="white" /> <Plus size={theme.iconSize.lg} color="white" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}> <View style={styles.tooltipRow}>
@@ -791,7 +791,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{isSubmitLoading ? ( {isSubmitLoading ? (
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : (
<ArrowUp size={20} color="white" /> <ArrowUp size={theme.iconSize.lg} color="white" />
)} )}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
@@ -54,9 +54,9 @@ export function RealtimeVoiceOverlay({
]} ]}
> >
{isMuted ? ( {isMuted ? (
<MicOff size={20} color={theme.colors.palette.white} strokeWidth={2.5} /> <MicOff size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
) : ( ) : (
<Mic size={20} color={theme.colors.foreground} strokeWidth={2.5} /> <Mic size={theme.iconSize.lg} color={theme.colors.foreground} strokeWidth={2.5} />
)} )}
</Pressable> </Pressable>
@@ -75,7 +75,7 @@ export function RealtimeVoiceOverlay({
<ActivityIndicator size="small" color={theme.colors.palette.white} /> <ActivityIndicator size="small" color={theme.colors.palette.white} />
) : ( ) : (
<Square <Square
size={20} size={theme.iconSize.lg}
color={theme.colors.palette.white} color={theme.colors.palette.white}
fill={theme.colors.palette.white} fill={theme.colors.palette.white}
strokeWidth={2.5} strokeWidth={2.5}
@@ -116,7 +116,7 @@ function ProjectFilterOptionRow({
{option.activeCount > 0 ? ( {option.activeCount > 0 ? (
<Text style={styles.filterOptionCount}>{option.activeCount}</Text> <Text style={styles.filterOptionCount}>{option.activeCount}</Text>
) : null} ) : null}
{selected ? <Check size={14} color={theme.colors.foregroundMuted} /> : null} {selected ? <Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> : null}
</View> </View>
</Pressable> </Pressable>
); );
@@ -247,7 +247,7 @@ function SidebarAgentRow({
]} ]}
> >
{isBatchSelected ? ( {isBatchSelected ? (
<Check size={12} color={theme.colors.primaryForeground} /> <Check size={theme.iconSize.xs} color={theme.colors.primaryForeground} />
) : null} ) : null}
</Pressable> </Pressable>
) : ( ) : (
@@ -301,10 +301,10 @@ function SidebarAgentRow({
isArchiving ? ( isArchiving ? (
<ActivityIndicator size="small" color={theme.colors.foreground} /> <ActivityIndicator size="small" color={theme.colors.foreground} />
) : isArchiveConfirmVisible ? ( ) : isArchiveConfirmVisible ? (
<Check size={12} color={theme.colors.foreground} /> <Check size={theme.iconSize.xs} color={theme.colors.foreground} />
) : ( ) : (
<Archive <Archive
size={12} size={theme.iconSize.xs}
color={ color={
archiveHovered archiveHovered
? theme.colors.foreground ? theme.colors.foreground
@@ -785,7 +785,7 @@ export function SidebarAgentList({
{selectedProjectLabel} {selectedProjectLabel}
</Text> </Text>
<ChevronDown <ChevronDown
size={14} size={theme.iconSize.sm}
color={ color={
showActiveForeground showActiveForeground
? theme.colors.foreground ? theme.colors.foreground
@@ -916,13 +916,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
selectedProjectIcon: { selectedProjectIcon: {
width: 14, width: theme.iconSize.sm,
height: 14, height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm, borderRadius: theme.borderRadius.sm,
}, },
projectCountBadge: { projectCountBadge: {
minWidth: 16, minWidth: theme.iconSize.md,
height: 16, height: theme.iconSize.md,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -987,13 +987,13 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
}, },
projectIcon: { projectIcon: {
width: 14, width: theme.iconSize.sm,
height: 14, height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm, borderRadius: theme.borderRadius.sm,
}, },
projectIconFallback: { projectIconFallback: {
width: 14, width: theme.iconSize.sm,
height: 14, height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm, borderRadius: theme.borderRadius.sm,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
@@ -1039,8 +1039,8 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 20, minHeight: 20,
}, },
checkbox: { checkbox: {
width: 16, width: theme.iconSize.md,
height: 16, height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm, borderRadius: theme.borderRadius.sm,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
@@ -1100,8 +1100,8 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
agentMetaProjectIcon: { agentMetaProjectIcon: {
width: 14, width: theme.iconSize.sm,
height: 14, height: theme.iconSize.sm,
borderRadius: theme.borderRadius.sm, borderRadius: theme.borderRadius.sm,
}, },
agentMetaProjectIconInactive: { agentMetaProjectIconInactive: {
@@ -356,7 +356,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
> >
{({ hovered }) => ( {({ hovered }) => (
<> <>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} /> <Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text> <Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</> </>
)} )}
@@ -418,7 +421,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
> >
{({ hovered }) => ( {({ hovered }) => (
<Users <Users
size={20} size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
)} )}
@@ -435,7 +438,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
> >
{({ hovered }) => ( {({ hovered }) => (
<Settings <Settings
size={20} size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
)} )}
@@ -479,7 +482,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
> >
{({ hovered }) => ( {({ hovered }) => (
<> <>
<Plus size={18} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} /> <Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text> <Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text>
</> </>
)} )}
@@ -539,7 +545,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
> >
{({ hovered }) => ( {({ hovered }) => (
<Users <Users
size={20} size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
)} )}
@@ -555,7 +561,10 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
onPress={handleSettingsDesktop} onPress={handleSettingsDesktop}
> >
{({ hovered }) => ( {({ hovered }) => (
<Settings size={20} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} /> <Settings
size={theme.iconSize.lg}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)} )}
</Pressable> </Pressable>
</View> </View>
@@ -38,7 +38,7 @@ export function SegmentedControl<T extends string>({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd; const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd; const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? 14 : 16; const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
return ( return (
<View style={[styles.container, style]} testID={testID}> <View style={[styles.container, style]} testID={testID}>
@@ -12,6 +12,17 @@ import { usePanelStore } from "@/stores/panel-store";
const ANIMATION_DURATION = 220; const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerAnimation(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[ExplorerAnimation] ${event}`, details);
}
interface ExplorerSidebarAnimationContextValue { interface ExplorerSidebarAnimationContextValue {
translateX: SharedValue<number>; translateX: SharedValue<number>;
@@ -50,13 +61,28 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (prevIsOpen.current === isOpen) { if (prevIsOpen.current === isOpen) {
return; return;
} }
const previousIsOpen = prevIsOpen.current;
prevIsOpen.current = isOpen; prevIsOpen.current = isOpen;
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it // Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) { if (isGesturing.value) {
logExplorerAnimation("sync-skipped-during-gesture", {
previousIsOpen,
nextIsOpen: isOpen,
mobileView,
desktopFileExplorerOpen,
});
return; return;
} }
logExplorerAnimation("sync-state-change", {
previousIsOpen,
nextIsOpen: isOpen,
mobileView,
desktopFileExplorerOpen,
windowWidth,
});
if (isOpen) { if (isOpen) {
translateX.value = withTiming(0, { translateX.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
@@ -76,7 +102,15 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
} }
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); }, [
isOpen,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
mobileView,
desktopFileExplorerOpen,
]);
const animateToOpen = () => { const animateToOpen = () => {
"worklet"; "worklet";
@@ -51,4 +51,19 @@ describe("useArchiveAgent", () => {
}) })
).toBe(false); ).toBe(false);
}); });
it("removes an archived agent from cached list payloads", () => {
const payload = {
entries: [
{ agent: { id: "agent-1" } },
{ agent: { id: "agent-2" } },
],
pageInfo: { hasMore: false },
};
const next = __private__.removeAgentFromListPayload(payload, "agent-1");
expect(next.entries).toEqual([{ agent: { id: "agent-2" } }]);
expect(next.pageInfo).toEqual({ hasMore: false });
});
}); });
+82 -3
View File
@@ -20,6 +20,10 @@ interface IsAgentArchivingInput extends ArchiveAgentInput {
queryClient: QueryClient; queryClient: QueryClient;
} }
interface AgentsListQueryData {
entries?: Array<{ agent?: { id?: string | null } | null } | null>;
}
function toArchiveKey(input: ArchiveAgentInput): string { function toArchiveKey(input: ArchiveAgentInput): string {
const serverId = input.serverId.trim(); const serverId = input.serverId.trim();
const agentId = input.agentId.trim(); const agentId = input.agentId.trim();
@@ -73,6 +77,66 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean {
return Boolean(readPendingState(input.queryClient)[key]); return Boolean(readPendingState(input.queryClient)[key]);
} }
function removeAgentFromListPayload<T extends AgentsListQueryData | undefined>(
payload: T,
agentId: string
): T {
if (!payload || !Array.isArray(payload.entries) || !agentId) {
return payload;
}
const filtered = payload.entries.filter((entry) => entry?.agent?.id !== agentId);
if (filtered.length === payload.entries.length) {
return payload;
}
return {
...payload,
entries: filtered,
} as T;
}
function removeAgentFromCachedLists(
queryClient: QueryClient,
input: ArchiveAgentInput
): void {
const agentId = input.agentId.trim();
if (!agentId) {
return;
}
queryClient.setQueryData<AgentsListQueryData | undefined>(
["sidebarAgentsList", input.serverId],
(current) => removeAgentFromListPayload(current, agentId)
);
queryClient.setQueryData<AgentsListQueryData | undefined>(
["allAgents", input.serverId],
(current) => removeAgentFromListPayload(current, agentId)
);
}
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
const archivedAt = new Date(input.archivedAt);
if (Number.isNaN(archivedAt.getTime())) {
return;
}
const setAgents = useSessionStore.getState().setAgents;
setAgents(input.serverId, (prev) => {
const existing = prev.get(input.agentId);
if (!existing) {
return prev;
}
if (existing.archivedAt && existing.archivedAt.getTime() === archivedAt.getTime()) {
return prev;
}
const next = new Map(prev);
next.set(input.agentId, {
...existing,
archivedAt,
});
return next;
});
}
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void { export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
setAgentArchiving({ setAgentArchiving({
...input, ...input,
@@ -92,12 +156,12 @@ export function useArchiveAgent() {
}); });
const archiveMutation = useMutation({ const archiveMutation = useMutation({
mutationFn: async (input: ArchiveAgentInput) => { mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null; const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
if (!client) { if (!client) {
throw new Error("Daemon client not available"); throw new Error("Daemon client not available");
} }
await client.archiveAgent(input.agentId); return await client.archiveAgent(input.agentId);
}, },
onMutate: (input) => { onMutate: (input) => {
setAgentArchiving({ setAgentArchiving({
@@ -107,7 +171,21 @@ export function useArchiveAgent() {
isArchiving: true, isArchiving: true,
}); });
}, },
onError: (_error, input) => { onSuccess: (result, input) => {
markAgentArchivedInStore({
serverId: input.serverId,
agentId: input.agentId,
archivedAt: result.archivedAt,
});
removeAgentFromCachedLists(queryClient, input);
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
},
onSettled: (_result, _error, input) => {
clearArchiveAgentPending({ clearArchiveAgentPending({
queryClient, queryClient,
serverId: input.serverId, serverId: input.serverId,
@@ -154,4 +232,5 @@ export const __private__ = {
readPendingState, readPendingState,
setAgentArchiving, setAgentArchiving,
isAgentArchiving, isAgentArchiving,
removeAgentFromListPayload,
}; };
@@ -0,0 +1,138 @@
import { useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import {
Extrapolation,
interpolate,
runOnJS,
useSharedValue,
} from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
interface UseExplorerOpenGestureParams {
enabled: boolean;
onOpen: () => void;
}
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerOpenGesture(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
console.log(`[ExplorerOpenGesture] ${event}`, details);
}
export function useExplorerOpenGesture({
enabled,
onOpen,
}: UseExplorerOpenGestureParams) {
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
isGesturing,
} = useExplorerSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
return useMemo(
() =>
Gesture.Pan()
.enabled(enabled)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
// Fail quickly on rightward or clearly vertical intent.
if (deltaX >= 10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
// Activate only on intentional leftward movement.
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
runOnJS(logExplorerOpenGesture)("start", { enabled });
})
.onUpdate((event) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0.
const newTranslateX = Math.max(
0,
Math.min(windowWidth, windowWidth + event.translationX)
);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[windowWidth, 0],
[0, 1],
Extrapolation.CLAMP
);
})
.onEnd((event) => {
isGesturing.value = false;
const shouldOpenByPosition = translateX.value < (windowWidth * 2) / 3;
const shouldOpenByVelocity = event.velocityX < -500;
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
runOnJS(logExplorerOpenGesture)("end", {
translationX: event.translationX,
velocityX: event.velocityX,
panelTranslateX: translateX.value,
windowWidth,
shouldOpenByPosition,
shouldOpenByVelocity,
shouldOpen,
});
if (shouldOpen) {
animateToOpen();
runOnJS(onOpen)();
} else {
animateToClose();
}
})
.onFinalize(() => {
isGesturing.value = false;
}),
[
enabled,
windowWidth,
translateX,
backdropOpacity,
animateToOpen,
animateToClose,
isGesturing,
onOpen,
touchStartX,
touchStartY,
]
);
}
@@ -17,11 +17,8 @@ import { useQueryClient } from "@tanstack/react-query";
import ReanimatedAnimated, { import ReanimatedAnimated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
runOnJS,
interpolate,
Extrapolation,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { import {
MoreVertical, MoreVertical,
@@ -41,7 +38,6 @@ import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { import {
ExplorerSidebarAnimationProvider, ExplorerSidebarAnimationProvider,
useExplorerSidebarAnimation,
} from "@/contexts/explorer-sidebar-animation-context"; } from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
@@ -76,6 +72,7 @@ import {
import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { FetchAgentsEntry } from "@server/client/daemon-client"; import type { FetchAgentsEntry } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -90,6 +87,14 @@ const DROPDOWN_WIDTH = 220;
const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const RECONNECT_NOTICE_DELAY_MS = 10_000; const RECONNECT_NOTICE_DELAY_MS = 10_000;
const CONNECTED_NOTICE_DURATION_MS = 2_500; const CONNECTED_NOTICE_DURATION_MS = 2_500;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logAgentExplorer(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return;
}
console.log(`[AgentExplorer] ${event}`, details);
}
export function AgentReadyScreen({ export function AgentReadyScreen({
serverId, serverId,
@@ -324,31 +329,27 @@ function AgentScreenContent({
}, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]); }, [resolveCachedCheckoutIsGit, resolvedAgentId, checkout?.isGit, serverId]);
const openExplorerForActiveCheckout = useCallback(() => { const openExplorerForActiveCheckout = useCallback(() => {
const checkoutContext = resolveCurrentExplorerCheckout(); const checkoutContext = resolveCurrentExplorerCheckout();
logAgentExplorer("openExplorerForActiveCheckout", {
hasCheckoutContext: Boolean(checkoutContext),
checkoutContext,
});
if (checkoutContext) { if (checkoutContext) {
activateExplorerTabForCheckout(checkoutContext); activateExplorerTabForCheckout(checkoutContext);
} }
openFileExplorer(); openFileExplorer();
}, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]); }, [activateExplorerTabForCheckout, openFileExplorer, resolveCurrentExplorerCheckout]);
const handleToggleExplorer = useCallback(() => { const handleToggleExplorer = useCallback(() => {
logAgentExplorer("handleToggleExplorer", {
isExplorerOpen,
mobileView,
isMobile,
});
if (isExplorerOpen) { if (isExplorerOpen) {
toggleFileExplorer(); toggleFileExplorer();
return; return;
} }
openExplorerForActiveCheckout(); openExplorerForActiveCheckout();
}, [isExplorerOpen, openExplorerForActiveCheckout, toggleFileExplorer]); }, [isExplorerOpen, isMobile, mobileView, openExplorerForActiveCheckout, toggleFileExplorer]);
const {
translateX: explorerTranslateX,
backdropOpacity: explorerBackdropOpacity,
windowWidth: explorerWindowWidth,
animateToOpen: animateExplorerToOpen,
animateToClose: animateExplorerToClose,
isGesturing: isExplorerGesturing,
} = useExplorerSidebarAnimation();
const handleOpenExplorerFromGesture = useCallback(() => {
openExplorerForActiveCheckout();
animateExplorerToOpen();
}, [animateExplorerToOpen, openExplorerForActiveCheckout]);
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
@@ -360,53 +361,10 @@ function AgentScreenContent({
}, [serverId, agentId]); }, [serverId, agentId]);
// Swipe-left gesture to open explorer sidebar on mobile // Swipe-left gesture to open explorer sidebar on mobile
const explorerOpenGesture = useMemo( const explorerOpenGesture = useExplorerOpenGesture({
() => enabled: isMobile && mobileView === "agent",
Gesture.Pan() onOpen: openExplorerForActiveCheckout,
.enabled(isMobile && !isExplorerOpen) });
// Only activate after 15px horizontal movement to the left (negative)
.activeOffsetX(-15)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
.onStart(() => {
isExplorerGesturing.value = true;
})
.onUpdate((event) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0
// Swiping left means negative translationX
const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX);
explorerTranslateX.value = newTranslateX;
explorerBackdropOpacity.value = interpolate(
newTranslateX,
[explorerWindowWidth, 0],
[0, 1],
Extrapolation.CLAMP
);
})
.onEnd((event) => {
isExplorerGesturing.value = false;
// Open if dragged more than 1/3 of window or fast swipe left
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
if (shouldOpen) {
runOnJS(handleOpenExplorerFromGesture)();
} else {
animateExplorerToClose();
}
})
.onFinalize(() => {
isExplorerGesturing.value = false;
}),
[
isMobile,
isExplorerOpen,
explorerWindowWidth,
explorerTranslateX,
explorerBackdropOpacity,
animateExplorerToClose,
handleOpenExplorerFromGesture,
isExplorerGesturing,
]
);
// Handle hardware back button - close explorer sidebar first, then navigate back // Handle hardware back button - close explorer sidebar first, then navigate back
useEffect(() => { useEffect(() => {
@@ -947,7 +905,7 @@ function AgentScreenContent({
await Clipboard.setStringAsync(value); await Clipboard.setStringAsync(value);
toast.show(`Copied ${label}`, { toast.show(`Copied ${label}`, {
variant: "success", variant: "success",
icon: <CheckCircle2 size={16} color={theme.colors.primary} />, icon: <CheckCircle2 size={theme.iconSize.md} color={theme.colors.primary} />,
}); });
} catch { } catch {
toast.error("Copy failed"); toast.error("Copy failed");
@@ -1015,7 +973,7 @@ function AgentScreenContent({
{isMobile ? ( {isMobile ? (
checkout?.isGit ? ( checkout?.isGit ? (
<GitBranch <GitBranch
size={20} size={theme.iconSize.lg}
color={ color={
isExplorerOpen isExplorerOpen
? theme.colors.foreground ? theme.colors.foreground
@@ -1024,7 +982,7 @@ function AgentScreenContent({
/> />
) : ( ) : (
<Folder <Folder
size={20} size={theme.iconSize.lg}
color={ color={
isExplorerOpen isExplorerOpen
? theme.colors.foreground ? theme.colors.foreground
@@ -1034,7 +992,7 @@ function AgentScreenContent({
) )
) : ( ) : (
<PanelRight <PanelRight
size={16} size={theme.iconSize.md}
color={ color={
isExplorerOpen isExplorerOpen
? theme.colors.foreground ? theme.colors.foreground
@@ -1051,7 +1009,10 @@ function AgentScreenContent({
}} }}
> >
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}> <DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
<MoreVertical size={isMobile ? 20 : 16} color={theme.colors.foregroundMuted} /> <MoreVertical
size={isMobile ? theme.iconSize.lg : theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content"> <DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}> <View style={styles.menuMetaContainer}>
@@ -1170,7 +1131,7 @@ function AgentScreenContent({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
leading={<RotateCcw size={16} color={theme.colors.foreground} />} leading={<RotateCcw size={theme.iconSize.md} color={theme.colors.foreground} />}
disabled={isInitializing || shouldBlockForHistorySync} disabled={isInitializing || shouldBlockForHistorySync}
trailing={ trailing={
isInitializing ? ( isInitializing ? (
@@ -1202,7 +1163,7 @@ function AgentScreenContent({
> >
{showConnectedNotice ? ( {showConnectedNotice ? (
<CheckCircle2 <CheckCircle2
size={14} size={theme.iconSize.sm}
color={theme.colors.palette.green[600]} color={theme.colors.palette.green[600]}
/> />
) : null} ) : null}
@@ -3,11 +3,12 @@ import { createNameId } from 'mnemonic-id'
import type { ImageAttachment } from '@/components/message-input' import type { ImageAttachment } from '@/components/message-input'
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native' import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native'
import { useLocalSearchParams, useRouter } from 'expo-router' import { useLocalSearchParams, useRouter } from 'expo-router'
import { useIsFocused } from '@react-navigation/native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller' import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { GestureDetector } from 'react-native-gesture-handler'
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated' import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
import { Folder, GitBranch, PanelRight } from 'lucide-react-native' import { Folder, GitBranch, PanelRight } from 'lucide-react-native'
import { SidebarMenuToggle } from '@/components/headers/menu-header' import { SidebarMenuToggle } from '@/components/headers/menu-header'
import { HeaderToggleButton } from '@/components/headers/header-toggle-button' import { HeaderToggleButton } from '@/components/headers/header-toggle-button'
@@ -31,6 +32,7 @@ import { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/bran
import { shortenPath } from '@/utils/shorten-path' import { shortenPath } from '@/utils/shorten-path'
import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions' import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions'
import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions' import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions'
import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture'
import { useSessionStore } from '@/stores/session-store' import { useSessionStore } from '@/stores/session-store'
import { useCreateFlowStore } from '@/stores/create-flow-store' import { useCreateFlowStore } from '@/stores/create-flow-store'
import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context' import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context'
@@ -131,6 +133,7 @@ function DraftAgentScreenContent({
onCreateFlowActiveChange, onCreateFlowActiveChange,
forcedServerId, forcedServerId,
}: DraftAgentScreenProps = {}) { }: DraftAgentScreenProps = {}) {
const isFocused = useIsFocused()
const { theme } = useUnistyles() const { theme } = useUnistyles()
const router = useRouter() const router = useRouter()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
@@ -713,26 +716,10 @@ function DraftAgentScreenContent({
} }
openExplorerForDraftCheckout() openExplorerForDraftCheckout()
}, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer]) }, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer])
const handleOpenExplorerFromGesture = useCallback(() => { const explorerOpenGesture = useExplorerOpenGesture({
if (!canOpenExplorer) { enabled: isMobile && mobileView === 'agent' && canOpenExplorer,
return onOpen: openExplorerForDraftCheckout,
} })
openExplorerForDraftCheckout()
}, [canOpenExplorer, openExplorerForDraftCheckout])
const explorerOpenGesture = useMemo(
() =>
Gesture.Pan()
.enabled(isMobile && !isExplorerOpen && canOpenExplorer)
.activeOffsetX(-15)
.failOffsetY([-10, 10])
.onEnd((event) => {
const shouldOpen = event.translationX < -80 || event.velocityX < -500
if (shouldOpen) {
runOnJS(handleOpenExplorerFromGesture)()
}
}),
[canOpenExplorer, handleOpenExplorerFromGesture, isExplorerOpen, isMobile]
)
const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0 const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0
const workingDirSearchError = const workingDirSearchError =
directorySuggestionsQuery.error instanceof Error directorySuggestionsQuery.error instanceof Error
@@ -926,20 +913,23 @@ function DraftAgentScreenContent({
workingDir, workingDir,
]) ])
useEffect(() => { useEffect(() => {
if (!isFocused) {
return
}
setActiveExplorerCheckout(draftExplorerCheckout) setActiveExplorerCheckout(draftExplorerCheckout)
}, [draftExplorerCheckout, setActiveExplorerCheckout]) }, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout])
useEffect(() => { useEffect(() => {
if (!draftExplorerCheckout) { if (!isFocused || !draftExplorerCheckout) {
return return
} }
activateExplorerTabForCheckout(draftExplorerCheckout) activateExplorerTabForCheckout(draftExplorerCheckout)
}, [activateExplorerTabForCheckout, draftExplorerCheckout]) }, [activateExplorerTabForCheckout, draftExplorerCheckout, isFocused])
useEffect(() => { useEffect(() => {
if (canOpenExplorer || !isExplorerOpen) { if (!isFocused || canOpenExplorer || !isExplorerOpen) {
return return
} }
closeFileExplorer() closeFileExplorer()
}, [canOpenExplorer, closeFileExplorer, isExplorerOpen]) }, [canOpenExplorer, closeFileExplorer, isExplorerOpen, isFocused])
useEffect(() => { useEffect(() => {
return () => { return () => {
setActiveExplorerCheckout(null) setActiveExplorerCheckout(null)
@@ -1146,7 +1136,7 @@ function DraftAgentScreenContent({
accessibilityState={{ expanded: isExplorerOpen }} accessibilityState={{ expanded: isExplorerOpen }}
> >
<PanelRight <PanelRight
size={16} size={theme.iconSize.md}
color={isExplorerOpen ? theme.colors.foreground : theme.colors.foregroundMuted} color={isExplorerOpen ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
</HeaderToggleButton> </HeaderToggleButton>
@@ -1179,7 +1169,7 @@ function DraftAgentScreenContent({
value={displayWorkingDir} value={displayWorkingDir}
placeholder="/path/to/project" placeholder="/path/to/project"
onPress={() => setIsWorkingDirOpen(true)} onPress={() => setIsWorkingDirOpen(true)}
icon={<Folder size={16} color={theme.colors.foregroundMuted} />} icon={<Folder size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
valueEllipsizeMode="middle" valueEllipsizeMode="middle"
testID="working-directory-select" testID="working-directory-select"
@@ -1226,7 +1216,7 @@ function DraftAgentScreenContent({
value={worktreeTriggerValue} value={worktreeTriggerValue}
placeholder="Select worktree" placeholder="Select worktree"
onPress={() => setIsWorktreePickerOpen(true)} onPress={() => setIsWorktreePickerOpen(true)}
icon={<GitBranch size={16} color={theme.colors.foregroundMuted} />} icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
valueEllipsizeMode="middle" valueEllipsizeMode="middle"
/> />
@@ -1239,7 +1229,7 @@ function DraftAgentScreenContent({
placeholder="From branch" placeholder="From branch"
onPress={() => setIsBranchOpen(true)} onPress={() => setIsBranchOpen(true)}
disabled={repoInfoStatus === 'loading'} disabled={repoInfoStatus === 'loading'}
icon={<GitBranch size={16} color={theme.colors.foregroundMuted} />} icon={<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false} showLabel={false}
/> />
) : null} ) : null}
+116 -34
View File
@@ -51,6 +51,23 @@ export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2; export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8; export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logPanelTransition(
action: string,
details: Record<string, unknown>
): void {
if (!IS_DEV) {
return;
}
const stack =
new Error().stack
?.split("\n")
.slice(2, 8)
.join("\n") ?? "stack unavailable";
console.log(`[PanelStore] ${action}`, details, stack);
}
interface PanelState { interface PanelState {
// Mobile: which panel is currently shown // Mobile: which panel is currently shown
mobileView: MobilePanelView; mobileView: MobilePanelView;
@@ -134,53 +151,102 @@ export const usePanelStore = create<PanelState>()(
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO, explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
openAgentList: () => openAgentList: () =>
set((state) => ({ set((state) => {
mobileView: "agent-list", const nextState = {
desktop: { ...state.desktop, agentListOpen: true }, mobileView: "agent-list" as const,
})), desktop: { ...state.desktop, agentListOpen: true },
};
logPanelTransition("openAgentList", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopAgentListOpen: state.desktop.agentListOpen,
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
});
return nextState;
}),
openFileExplorer: () => openFileExplorer: () =>
set((state) => { set((state) => {
const resolvedTab = resolveExplorerTabFromActiveCheckout(state); const resolvedTab = resolveExplorerTabFromActiveCheckout(state);
return { const nextMobileView: MobilePanelView = "file-explorer";
mobileView: "file-explorer", const nextDesktop = { ...state.desktop, fileExplorerOpen: true };
desktop: { ...state.desktop, fileExplorerOpen: true }, const nextState = {
mobileView: nextMobileView,
desktop: nextDesktop,
...(resolvedTab ? { explorerTab: resolvedTab } : {}), ...(resolvedTab ? { explorerTab: resolvedTab } : {}),
}; };
logPanelTransition("openFileExplorer", {
fromMobileView: state.mobileView,
toMobileView: nextMobileView,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen,
resolvedTab: resolvedTab ?? null,
activeCheckout: state.activeExplorerCheckout,
});
return nextState;
}), }),
closeFileExplorer: () => closeFileExplorer: () =>
set((state) => ({ set((state) => {
mobileView: state.mobileView === "file-explorer" ? "agent" : state.mobileView, const nextState = {
desktop: { mobileView:
...state.desktop, state.mobileView === "file-explorer" ? ("agent" as const) : state.mobileView,
fileExplorerOpen: false, desktop: {
}, ...state.desktop,
})), fileExplorerOpen: false,
},
};
logPanelTransition("closeFileExplorer", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen,
});
return nextState;
}),
closeToAgent: () => closeToAgent: () =>
set((state) => ({ set((state) => {
mobileView: "agent", const nextState = {
// On desktop, closing depends on which panel triggered it mobileView: "agent" as const,
// This is called when closing via gesture/backdrop, so we close the currently active mobile panel // On desktop, closing depends on which panel triggered it
desktop: { // This is called when closing via gesture/backdrop, so we close the currently active mobile panel
agentListOpen: desktop: {
state.mobileView === "agent-list" ? false : state.desktop.agentListOpen, agentListOpen:
fileExplorerOpen: state.mobileView === "agent-list" ? false : state.desktop.agentListOpen,
state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen, fileExplorerOpen:
}, state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen,
})), },
};
logPanelTransition("closeToAgent", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopAgentListOpen: state.desktop.agentListOpen,
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextState.desktop.fileExplorerOpen,
});
return nextState;
}),
toggleAgentList: () => toggleAgentList: () =>
set((state) => { set((state) => {
// Mobile: toggle between agent and agent-list // Mobile: toggle between agent and agent-list
const newMobileView = state.mobileView === "agent-list" ? "agent" : "agent-list"; const newMobileView: MobilePanelView =
return { state.mobileView === "agent-list" ? "agent" : "agent-list";
const nextState = {
mobileView: newMobileView, mobileView: newMobileView,
desktop: { desktop: {
...state.desktop, ...state.desktop,
agentListOpen: !state.desktop.agentListOpen, agentListOpen: !state.desktop.agentListOpen,
}, },
}; };
logPanelTransition("toggleAgentList", {
fromMobileView: state.mobileView,
toMobileView: nextState.mobileView,
fromDesktopAgentListOpen: state.desktop.agentListOpen,
toDesktopAgentListOpen: nextState.desktop.agentListOpen,
});
return nextState;
}), }),
toggleFileExplorer: () => toggleFileExplorer: () =>
@@ -188,19 +254,35 @@ export const usePanelStore = create<PanelState>()(
// Mobile: toggle between agent and file-explorer // Mobile: toggle between agent and file-explorer
const willOpenMobile = state.mobileView !== "file-explorer"; const willOpenMobile = state.mobileView !== "file-explorer";
const willOpenDesktop = !state.desktop.fileExplorerOpen; const willOpenDesktop = !state.desktop.fileExplorerOpen;
const nextState: Partial<PanelState> = { const nextMobileView: MobilePanelView = willOpenMobile
mobileView: willOpenMobile ? "file-explorer" : "agent", ? "file-explorer"
desktop: { : "agent";
...state.desktop, const nextDesktop = {
fileExplorerOpen: willOpenDesktop, ...state.desktop,
}, fileExplorerOpen: willOpenDesktop,
}; };
const nextState: Pick<PanelState, "mobileView" | "desktop"> &
Partial<Pick<PanelState, "explorerTab">> = {
mobileView: nextMobileView,
desktop: nextDesktop,
};
let resolvedTab: ExplorerTab | null = null;
if (willOpenMobile || willOpenDesktop) { if (willOpenMobile || willOpenDesktop) {
const resolvedTab = resolveExplorerTabFromActiveCheckout(state); resolvedTab = resolveExplorerTabFromActiveCheckout(state);
if (resolvedTab) { if (resolvedTab) {
nextState.explorerTab = resolvedTab; nextState.explorerTab = resolvedTab;
} }
} }
logPanelTransition("toggleFileExplorer", {
fromMobileView: state.mobileView,
toMobileView: nextMobileView,
fromDesktopFileExplorerOpen: state.desktop.fileExplorerOpen,
toDesktopFileExplorerOpen: nextDesktop.fileExplorerOpen,
willOpenMobile,
willOpenDesktop,
resolvedTab: resolvedTab ?? null,
activeCheckout: state.activeExplorerCheckout,
});
return nextState; return nextState;
}), }),
+7
View File
@@ -215,6 +215,13 @@ const commonTheme = {
"4xl": 34, "4xl": 34,
}, },
iconSize: {
xs: 12,
sm: 14,
md: 16,
lg: 20,
},
fontWeight: { fontWeight: {
normal: "normal" as const, normal: "normal" as const,
medium: "500" as const, medium: "500" as const,
@@ -168,6 +168,34 @@ describe("daemon client E2E", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, 30000); }, 30000);
test("archives agents and excludes them from default listings", async () => {
const cwd = tmpCwd();
try {
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd,
},
});
await ctx.client.archiveAgent(created.id);
const active = await ctx.client.fetchAgents();
expect(active.entries.some((entry) => entry.agent.id === created.id)).toBe(false);
const withArchived = await ctx.client.fetchAgents({
filter: { includeArchived: true },
});
expect(withArchived.entries.some((entry) => entry.agent.id === created.id)).toBe(true);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
test("returns rpc error when archiving an unknown agent id", async () => {
await expect(ctx.client.archiveAgent(randomUUID())).rejects.toThrow();
}, 10000);
test("returns home-scoped directory suggestions", async () => { test("returns home-scoped directory suggestions", async () => {
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-")); const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-")); const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
+27 -19
View File
@@ -1675,22 +1675,32 @@ export class Session {
this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`) this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`)
const archivedAt = new Date().toISOString() const archivedAt = new Date().toISOString()
let archivedRecord: StoredAgentRecord | null = null
try { const existing = await this.agentStorage.get(agentId)
const existing = await this.agentStorage.get(agentId) let archivedRecord: StoredAgentRecord | null = existing
if (existing) { if (!archivedRecord) {
archivedRecord = { const liveAgent = this.agentManager.getAgent(agentId)
...existing, if (!liveAgent) {
archivedAt, throw new Error(`Agent not found: ${agentId}`)
} }
await this.agentStorage.upsert(archivedRecord)
await this.agentStorage.applySnapshot(liveAgent, {
title: liveAgent.config.title ?? null,
internal: liveAgent.internal,
})
archivedRecord = await this.agentStorage.get(agentId)
if (!archivedRecord) {
throw new Error(`Agent not found in storage after snapshot: ${agentId}`)
} }
this.agentManager.notifyAgentState(agentId)
} catch (error: any) {
this.sessionLogger.error({ err: error, agentId }, `Failed to archive agent ${agentId}`)
} }
archivedRecord = {
...archivedRecord,
archivedAt,
}
await this.agentStorage.upsert(archivedRecord)
this.agentManager.notifyAgentState(agentId)
this.emit({ this.emit({
type: 'agent_archived', type: 'agent_archived',
payload: { payload: {
@@ -1700,13 +1710,11 @@ export class Session {
}, },
}) })
if (archivedRecord) { await this.maybeArchiveWorktreeAfterLastAgentArchived({
await this.maybeArchiveWorktreeAfterLastAgentArchived({ archivedAgentId: agentId,
archivedAgentId: agentId, archivedAgentCwd: archivedRecord.cwd,
archivedAgentCwd: archivedRecord.cwd, requestId,
requestId, })
})
}
} }
private async handleUpdateAgentRequest( private async handleUpdateAgentRequest(
@@ -118,6 +118,16 @@ describe("createWorktree", () => {
expect(ownership.repoRoot).toBe(repoDir); expect(ownership.repoRoot).toBe(repoDir);
}); });
it("treats non-git directories as non-worktrees without throwing", async () => {
const nonGitDir = join(tempDir, "not-a-repo");
execSync(`mkdir -p ${nonGitDir}`);
const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome });
expect(ownership.allowed).toBe(false);
expect(ownership.worktreePath).toBe(realpathSync(nonGitDir));
});
it("creates a worktree with a new branch", async () => { it("creates a worktree with a new branch", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir); const projectHash = await deriveWorktreeProjectHash(repoDir);
const result = await createWorktree({ const result = await createWorktree({
+9 -1
View File
@@ -692,7 +692,15 @@ export async function isPaseoOwnedWorktreeCwd(
cwd: string, cwd: string,
options?: { paseoHome?: string } options?: { paseoHome?: string }
): Promise<PaseoWorktreeOwnership> { ): Promise<PaseoWorktreeOwnership> {
const gitCommonDir = await getGitCommonDir(cwd); let gitCommonDir: string;
try {
gitCommonDir = await getGitCommonDir(cwd);
} catch {
return {
allowed: false,
worktreePath: normalizePathForOwnership(cwd),
};
}
const repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir); const repoRoot = resolveRepoRootFromGitCommonDir(gitCommonDir);
const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome); const worktreesRoot = await getPaseoWorktreesRoot(cwd, options?.paseoHome);
const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep; const resolvedRoot = normalizePathForOwnership(worktreesRoot) + sep;