diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index c8e1e7079..9b47a7594 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -18,6 +18,7 @@ import { } from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { HEADER_INNER_HEIGHT } from "@/constants/layout"; +import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { GitDiffPane } from "./git-diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; @@ -250,33 +251,40 @@ function SidebarContent({ isMobile, }: SidebarContentProps) { const { theme } = useUnistyles(); + const { status } = useCheckoutStatusQuery({ serverId, agentId, cwd }); + const isGit = status?.isGit ?? false; + + // If not a git repo, only show files tab + const effectiveTab = isGit ? activeTab : "files"; return ( {/* Header with tabs and close button */} - onTabPress("changes")} - > - onTabPress("changes")} > - Changes - - + + Changes + + + ) : null} onTabPress("files")} > Files @@ -284,7 +292,7 @@ function SidebarContent({ - {activeTab === "files" && ( + {effectiveTab === "files" && ( )} {isMobile && ( @@ -297,10 +305,10 @@ function SidebarContent({ {/* Content based on active tab */} - {activeTab === "changes" && ( + {effectiveTab === "changes" && ( )} - {activeTab === "files" && ( + {effectiveTab === "files" && ( )} diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 9f04a8194..90b6378de 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react"; +import type { UseMutationResult } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import { View, @@ -6,6 +7,7 @@ import { ActivityIndicator, Pressable, FlatList, + Platform, type NativeSyntheticEvent, type NativeScrollEvent, type ListRenderItem, @@ -35,8 +37,72 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + type ActionStatus, } from "@/components/ui/dropdown-menu"; +// ============================================================================= +// Action Status Hook +// ============================================================================= +// Tracks mutation state with a brief success phase before returning to idle. +// State flow: idle → pending → success (1s) → idle +// ============================================================================= + +const SUCCESS_DISPLAY_MS = 1000; + +type ActionState = { + status: ActionStatus; + trigger: () => void; +}; + +function useActionStatus( + mutation: UseMutationResult, + onTrigger?: () => void +): ActionState { + const [showSuccess, setShowSuccess] = useState(false); + const successTimeoutRef = useRef | null>(null); + + // Clear timeout on unmount + useEffect(() => { + return () => { + if (successTimeoutRef.current) { + clearTimeout(successTimeoutRef.current); + } + }; + }, []); + + // Watch for mutation success to trigger success display + useEffect(() => { + if (mutation.isSuccess && !mutation.isPending) { + setShowSuccess(true); + successTimeoutRef.current = setTimeout(() => { + setShowSuccess(false); + mutation.reset(); + }, SUCCESS_DISPLAY_MS); + } + }, [mutation.isSuccess, mutation.isPending, mutation]); + + const status: ActionStatus = mutation.isPending + ? "pending" + : showSuccess + ? "success" + : "idle"; + + const trigger = useCallback(() => { + onTrigger?.(); + mutation.mutate(undefined as TVariables); + }, [mutation, onTrigger]); + + return { status, trigger }; +} + +function openURLInNewTab(url: string): void { + if (Platform.OS === "web") { + window.open(url, "_blank", "noopener"); + } else { + void Linking.openURL(url); + } +} + const DIFF_PANE_LOG_TAG = "[GitDiffPane]"; const DIFF_FILE_LOG_TAG = "[DiffFileSection]"; const DIFF_FILE_LOG_LINE_THRESHOLD = 500; @@ -665,6 +731,14 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { }, }); + // Wrap mutations with action status for UI feedback + const commitAction = useActionStatus(commitMutation); + const prCreateAction = useActionStatus(prMutation, () => void persistShipDefault("pr")); + const mergeAction = useActionStatus(mergeMutation, () => void persistShipDefault("merge")); + const mergeFromBaseAction = useActionStatus(mergeFromBaseMutation); + const pushAction = useActionStatus(pushMutation); + const archiveAction = useActionStatus(archiveMutation); + const renderFileSection: ListRenderItem = useCallback( ({ item, index }) => ( 0; const baseRefLabel = useMemo(() => { if (!baseRef) return "base"; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); @@ -777,46 +849,131 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { } const hasPullRequest = Boolean(prStatus?.url); - const prActionLabel = hasPullRequest ? "View PR" : "Create PR"; - type ShipActionKey = "merge" | "pr"; - const shipActions: { key: ShipActionKey; label: string; disabled: boolean; isPending: boolean }[] = - useMemo( - () => [ - { key: "merge", label: "Merge", disabled: mergeDisabled, isPending: mergeMutation.isPending }, - { key: "pr", label: prActionLabel, disabled: prDisabled, isPending: prMutation.isPending }, - ], - [mergeDisabled, mergeMutation.isPending, prActionLabel, prDisabled, prMutation.isPending] - ); + // ========================================================================== + // Primary CTA Logic + // ========================================================================== + // Rules (in priority order): + // 1. Uncommitted changes → "Commit" is primary + // 2. Has PR → "View PR" is primary + // 3. Ahead of base → "Merge branch" or "Create PR" based on shipDefault preference + // 4. Nothing to do → no primary CTA + // ========================================================================== - const resolvedShipPrimary: ShipActionKey | null = useMemo(() => { - if (!canShowShip) return null; - const preferred = shipDefault; - const preferredMeta = shipActions.find((action) => action.key === preferred); - if (preferredMeta && !preferredMeta.disabled) { - return preferred; + type PrimaryCTA = + | { type: "commit" } + | { type: "view-pr"; url: string } + | { type: "ship"; action: "merge" | "create-pr" } + | null; + + const primaryCTA: PrimaryCTA = useMemo(() => { + if (!isGit) return null; + + // Rule 1: Uncommitted changes → Commit + if (hasUncommittedChanges) { + return { type: "commit" }; } - const fallback = shipActions.find((action) => !action.disabled); - return fallback?.key ?? preferred; - }, [canShowShip, shipActions, shipDefault]); - const shipPrimaryDisabled = useMemo(() => { - if (!resolvedShipPrimary) return true; - if (actionsDisabled) return true; - const meta = shipActions.find((action) => action.key === resolvedShipPrimary); - return meta?.disabled ?? true; - }, [actionsDisabled, resolvedShipPrimary, shipActions]); + // Rule 2: Has PR → View PR + if (hasPullRequest && prStatus?.url) { + return { type: "view-pr", url: prStatus.url }; + } - const shipPrimaryPending = useMemo(() => { - if (!resolvedShipPrimary) return false; - if (resolvedShipPrimary === "merge") return mergeMutation.isPending; - // "pr" maps to open PR (instant) or create PR (mutation) - return hasPullRequest ? false : prMutation.isPending; - }, [hasPullRequest, mergeMutation.isPending, prMutation.isPending, resolvedShipPrimary]); + // Rule 3: Ahead of base → Ship (merge or create PR based on preference) + if (aheadCount > 0) { + const preferredAction = shipDefault === "merge" ? "merge" : "create-pr"; + // If preferred action is disabled, fall back to the other + if (preferredAction === "merge" && mergeDisabled && !prDisabled) { + return { type: "ship", action: "create-pr" }; + } + if (preferredAction === "create-pr" && prDisabled && !mergeDisabled) { + return { type: "ship", action: "merge" }; + } + return { type: "ship", action: preferredAction }; + } - // When there are uncommitted changes, Commit becomes the primary CTA - const showCommitAsPrimary = canShowCommit; - const showShipSplitButton = canShowShip && !showCommitAsPrimary; + // Rule 4: Nothing to do + return null; + }, [isGit, hasUncommittedChanges, hasPullRequest, prStatus?.url, aheadCount, shipDefault, mergeDisabled, prDisabled]); + + const primaryCTALabel = useMemo(() => { + if (!primaryCTA) return ""; + switch (primaryCTA.type) { + case "commit": + return "Commit"; + case "view-pr": + return "View PR"; + case "ship": + return primaryCTA.action === "merge" ? "Merge branch" : "Create PR"; + } + }, [primaryCTA]); + + const primaryCTADisabled = useMemo(() => { + if (!primaryCTA || actionsDisabled) return true; + switch (primaryCTA.type) { + case "commit": + return commitDisabled; + case "view-pr": + return false; // View PR is never disabled + case "ship": + return primaryCTA.action === "merge" ? mergeDisabled : prDisabled; + } + }, [primaryCTA, actionsDisabled, commitDisabled, mergeDisabled, prDisabled]); + + const primaryCTAStatus: ActionStatus = useMemo(() => { + if (!primaryCTA) return "idle"; + switch (primaryCTA.type) { + case "commit": + return commitAction.status; + case "view-pr": + return "idle"; // View PR is instant, no status + case "ship": + return primaryCTA.action === "merge" ? mergeAction.status : prCreateAction.status; + } + }, [primaryCTA, commitAction.status, mergeAction.status, prCreateAction.status]); + + const primaryCTADisplayLabel = useMemo(() => { + if (!primaryCTA) return ""; + const status = primaryCTAStatus; + + switch (primaryCTA.type) { + case "commit": + if (status === "pending") return "Committing..."; + if (status === "success") return "Committed"; + return "Commit"; + case "view-pr": + return "View PR"; + case "ship": + if (primaryCTA.action === "merge") { + if (status === "pending") return "Merging..."; + if (status === "success") return "Merged"; + return "Merge branch"; + } else { + if (status === "pending") return "Creating PR..."; + if (status === "success") return "PR Created"; + return "Create PR"; + } + } + }, [primaryCTA, primaryCTAStatus]); + + const handlePrimaryCTAPress = useCallback(() => { + if (!primaryCTA || primaryCTADisabled || primaryCTAStatus !== "idle") return; + switch (primaryCTA.type) { + case "commit": + commitAction.trigger(); + break; + case "view-pr": + openURLInNewTab(primaryCTA.url); + break; + case "ship": + if (primaryCTA.action === "merge") { + mergeAction.trigger(); + } else { + prCreateAction.trigger(); + } + break; + } + }, [primaryCTA, primaryCTADisabled, primaryCTAStatus, commitAction, mergeAction, prCreateAction]); return ( @@ -832,160 +989,98 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { {isGit ? ( - {showCommitAsPrimary ? ( - + {primaryCTA ? ( + commitMutation.mutate()} - disabled={commitDisabled} + onPress={handlePrimaryCTAPress} + disabled={primaryCTADisabled} accessibilityRole="button" - accessibilityLabel="Commit changes" + accessibilityLabel={primaryCTALabel} > - {commitMutation.isPending ? ( - + {primaryCTAStatus === "pending" ? ( + ) : ( - Commit + {primaryCTADisplayLabel} )} - - {canShowShip ? ( + + {/* Ship actions - only show if ahead of base */} + {aheadCount > 0 ? ( <> { - void persistShipDefault("merge"); - mergeMutation.mutate(); - }} + status={mergeAction.status} + pendingLabel="Merging..." + successLabel="Merged" + closeOnSelect={false} + description={hasUncommittedChanges ? "Requires clean working tree" : undefined} + onSelect={mergeAction.trigger} > Merge branch { - void persistShipDefault("pr"); if (hasPullRequest && prStatus?.url) { - void Linking.openURL(prStatus.url); + openURLInNewTab(prStatus.url); return; } - prMutation.mutate(); + prCreateAction.trigger(); }} > - {prActionLabel} + {hasPullRequest ? "View PR" : "Create PR"} ) : null} mergeFromBaseMutation.mutate()} + status={mergeFromBaseAction.status} + pendingLabel="Merging..." + successLabel="Merged" + closeOnSelect={false} + description={hasUncommittedChanges ? "Requires clean working tree" : undefined} + onSelect={mergeFromBaseAction.trigger} > Merge from {baseRefLabel} pushMutation.mutate()} + status={pushAction.status} + pendingLabel="Pushing..." + successLabel="Pushed" + closeOnSelect={false} + description={!(gitStatus?.hasRemote ?? false) ? "No remote configured" : undefined} + onSelect={pushAction.trigger} > Push to remote - ) : showShipSplitButton ? ( - - { - if (!resolvedShipPrimary || shipPrimaryDisabled) return; - await persistShipDefault(resolvedShipPrimary); - if (resolvedShipPrimary === "merge") { - if (mergeDisabled) return; - mergeMutation.mutate(); - return; - } - - if (hasPullRequest && prStatus?.url) { - void Linking.openURL(prStatus.url); - return; - } - - if (prDisabled) return; - prMutation.mutate(); - }} - disabled={shipPrimaryDisabled} - accessibilityRole="button" - accessibilityLabel="Ship changes" - > - {shipPrimaryPending ? ( - - ) : ( - - {resolvedShipPrimary === "merge" ? "Merge branch" : prActionLabel} - - )} - - - - - - - { - void persistShipDefault("merge"); - mergeMutation.mutate(); - }} - > - Merge branch - - - { - void persistShipDefault("pr"); - if (hasPullRequest && prStatus?.url) { - void Linking.openURL(prStatus.url); - return; - } - prMutation.mutate(); - }} - > - {prActionLabel} - - - - ) : null} - {canShowShip ? ( - <> - {resolvedShipPrimary === "merge" ? ( - { - void persistShipDefault("pr"); - if (hasPullRequest && prStatus?.url) { - void Linking.openURL(prStatus.url); - return; - } - prMutation.mutate(); - }} - > - {prActionLabel} - - ) : ( - { - void persistShipDefault("merge"); - mergeMutation.mutate(); - }} - > - Merge branch - - )} - - - ) : null} - mergeFromBaseMutation.mutate()} - > - Merge from {baseRefLabel} - - - pushMutation.mutate()} - > - Push to remote - - setDiffModeOverride(diffMode === "uncommitted" ? "base" : "uncommitted")} > {diffMode === "uncommitted" ? `Show changes vs ${baseRefLabel}` : "Show uncommitted changes"} - - archiveMutation.mutate()} - > - Archive worktree - + {gitStatus?.isPaseoOwnedWorktree ? ( + <> + + + Archive worktree + + + ) : null} ) : null} - {isGit ? ( + {isGit && hasChanges ? ( {diffMode === "uncommitted" ? "Uncommitted changes" : `Changes vs ${baseRefLabel}`} @@ -1132,7 +1184,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, - shipSplitButton: { + splitButton: { flexDirection: "row", alignItems: "stretch", borderRadius: theme.borderRadius.md, @@ -1141,25 +1193,25 @@ const styles = StyleSheet.create((theme) => ({ borderColor: theme.colors.borderAccent, overflow: "hidden", }, - shipPrimaryButton: { + splitButtonPrimary: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], justifyContent: "center", }, - shipPrimaryButtonDisabled: { + splitButtonPrimaryDisabled: { opacity: 0.6, }, - shipPrimaryText: { + splitButtonText: { fontSize: theme.fontSize.xs, lineHeight: theme.fontSize.xs * 1.5, color: theme.colors.foreground, fontWeight: theme.fontWeight.medium, }, - shipPrimarySpinner: { + splitButtonSpinner: { height: theme.fontSize.xs * 1.5, width: theme.fontSize.xs * 1.5, }, - shipCaretButton: { + splitButtonCaret: { width: 36, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index caa0f454a..f24088279 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -10,6 +10,7 @@ import { type ReactElement, } from "react"; import { + ActivityIndicator, Modal, Pressable, ScrollView, @@ -23,7 +24,10 @@ import { } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Check } from "lucide-react-native"; +import { Check, CheckCircle } from "lucide-react-native"; + +// Action status for menu items with loading/success feedback +export type ActionStatus = "idle" | "pending" | "success"; type Placement = "top" | "bottom" | "left" | "right"; type Alignment = "start" | "center" | "end"; @@ -384,6 +388,10 @@ export function DropdownMenuItem({ selectedVariant = "default", leading, trailing, + loading, + status, + pendingLabel, + successLabel, closeOnSelect = true, testID, }: PropsWithChildren<{ @@ -396,19 +404,50 @@ export function DropdownMenuItem({ selectedVariant?: "default" | "accent"; leading?: ReactElement | null; trailing?: ReactElement | null; + /** @deprecated Use `status` instead */ + loading?: boolean; + /** Action status: idle, pending, or success */ + status?: ActionStatus; + /** Label to show while pending (e.g., "Pushing...") */ + pendingLabel?: string; + /** Label to show on success (e.g., "Pushed") */ + successLabel?: string; closeOnSelect?: boolean; testID?: string; }>): ReactElement { const { theme } = useUnistyles(); const { setOpen } = useDropdownMenuContext("DropdownMenuItem"); + // Derive state from status prop (preferred) or legacy loading prop + const isPending = status === "pending" || loading; + const isSuccess = status === "success"; + const isDisabled = disabled || isPending || isSuccess; + + // Determine leading icon based on status + let leadingContent: ReactElement | null = null; + if (isPending) { + leadingContent = ; + } else if (isSuccess) { + leadingContent = ; + } else if (leading) { + leadingContent = leading; + } + + // Determine label based on status + let label = children; + if (isPending && pendingLabel) { + label = pendingLabel; + } else if (isSuccess && successLabel) { + label = successLabel; + } + return ( { - if (disabled) return; + if (isDisabled) return; if (closeOnSelect) { setOpen(false); } @@ -417,9 +456,9 @@ export function DropdownMenuItem({ style={({ pressed }) => [ styles.item, selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null, - destructive ? styles.itemDestructive : null, - disabled ? styles.itemDisabled : null, - pressed && !disabled ? styles.itemPressed : null, + destructive && !isSuccess ? styles.itemDestructive : null, + isDisabled ? styles.itemDisabled : null, + pressed && !isDisabled ? styles.itemPressed : null, ]} > {showSelectedCheck ? ( @@ -427,19 +466,20 @@ export function DropdownMenuItem({ {selected ? : null} ) : null} - {leading ? {leading} : null} + {leadingContent ? {leadingContent} : null} - {children} + {label} - {description ? ( + {description && !isPending && !isSuccess ? ( ({ itemTextDestructive: { color: theme.colors.destructive, }, + itemTextSuccess: { + color: theme.colors.palette.green[500], + }, itemTextSelectedAccent: { color: theme.colors.accentForeground, }, diff --git a/packages/server/src/server/agent/dictation-debug.ts b/packages/server/src/server/agent/dictation-debug.ts index 60f8a2647..af7a44408 100644 --- a/packages/server/src/server/agent/dictation-debug.ts +++ b/packages/server/src/server/agent/dictation-debug.ts @@ -4,8 +4,7 @@ import { join } from "path"; import { inferAudioExtension, sanitizeForFilename } from "./audio-utils.js"; import { resolveRecordingsDebugDir } from "./recordings-debug.js"; -const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR"); -let announced = false; +let announcedDir: string | null = null; export interface DictationDebugAudioMetadata { sessionId: string; @@ -18,13 +17,14 @@ export async function maybePersistDictationDebugAudio( metadata: DictationDebugAudioMetadata, logger: pino.Logger ): Promise { + const debugDir = resolveRecordingsDebugDir("DICTATION_DEBUG_AUDIO_DIR"); if (!debugDir) { return null; } - if (!announced) { + if (announcedDir !== debugDir) { logger.info({ debugDir }, "Dictation audio capture enabled"); - announced = true; + announcedDir = debugDir; } const timestamp = new Date().toISOString().replace(/[:.]/g, "-");