Update files

This commit is contained in:
Mohamed Boudra
2026-01-27 13:38:56 +07:00
parent 971da6f48a
commit a18739319f
4 changed files with 345 additions and 242 deletions

View File

@@ -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 (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={styles.header} testID="explorer-header">
<View style={styles.tabsContainer}>
<Pressable
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
]}
{isGit ? (
<Pressable
style={[styles.tab, effectiveTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
Changes
</Text>
</Pressable>
<Text
style={[
styles.tabText,
effectiveTab === "changes" && styles.tabTextActive,
]}
>
Changes
</Text>
</Pressable>
) : null}
<Pressable
style={[styles.tab, activeTab === "files" && styles.tabActive]}
style={[styles.tab, effectiveTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
activeTab === "files" && styles.tabTextActive,
effectiveTab === "files" && styles.tabTextActive,
]}
>
Files
@@ -284,7 +292,7 @@ function SidebarContent({
</Pressable>
</View>
<View style={styles.headerRightSection}>
{activeTab === "files" && (
{effectiveTab === "files" && (
<ViewToggle viewMode={fileViewMode} onChange={onFileViewModeChange} />
)}
{isMobile && (
@@ -297,10 +305,10 @@ function SidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{activeTab === "changes" && (
{effectiveTab === "changes" && (
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
)}
{activeTab === "files" && (
{effectiveTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
</View>

View File

@@ -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<TData, TError, TVariables, TContext>(
mutation: UseMutationResult<TData, TError, TVariables, TContext>,
onTrigger?: () => void
): ActionState {
const [showSuccess, setShowSuccess] = useState(false);
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<ParsedDiffFile> = useCallback(
({ item, index }) => (
<DiffFileSection
@@ -696,8 +770,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown");
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const canShowCommit = isGit && hasUncommittedChanges;
const canShowShip = isGit && aheadCount > 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 (
<View style={styles.container}>
@@ -832,160 +989,98 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
</View>
{isGit ? (
<View style={styles.headerRight}>
{showCommitAsPrimary ? (
<View style={styles.shipSplitButton}>
{primaryCTA ? (
<View style={styles.splitButton}>
<Pressable
testID="changes-commit-primary"
testID="changes-primary-cta"
style={[
styles.shipPrimaryButton,
commitDisabled && styles.shipPrimaryButtonDisabled,
styles.splitButtonPrimary,
primaryCTADisabled && styles.splitButtonPrimaryDisabled,
]}
onPress={() => commitMutation.mutate()}
disabled={commitDisabled}
onPress={handlePrimaryCTAPress}
disabled={primaryCTADisabled}
accessibilityRole="button"
accessibilityLabel="Commit changes"
accessibilityLabel={primaryCTALabel}
>
{commitMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} style={styles.shipPrimarySpinner} />
{primaryCTAStatus === "pending" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} style={styles.splitButtonSpinner} />
) : (
<Text style={styles.shipPrimaryText}>Commit</Text>
<Text style={styles.splitButtonText}>{primaryCTADisplayLabel}</Text>
)}
</Pressable>
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-commit-caret"
style={styles.shipCaretButton}
testID="changes-primary-cta-caret"
style={styles.splitButtonCaret}
accessibilityRole="button"
accessibilityLabel="More options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} testID="changes-commit-menu">
{canShowShip ? (
<DropdownMenuContent align="end" width={260} testID="changes-primary-cta-menu">
{/* Ship actions - only show if ahead of base */}
{aheadCount > 0 ? (
<>
<DropdownMenuItem
testID="changes-commit-menu-merge"
testID="changes-menu-merge"
disabled={mergeDisabled}
description={mergeDisabled && hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={() => {
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
</DropdownMenuItem>
<DropdownMenuItem
testID={hasPullRequest ? "changes-commit-menu-view-pr" : "changes-commit-menu-create-pr"}
disabled={prDisabled}
testID={hasPullRequest ? "changes-menu-view-pr" : "changes-menu-create-pr"}
disabled={!hasPullRequest && prDisabled}
status={!hasPullRequest ? prCreateAction.status : undefined}
pendingLabel="Creating PR..."
successLabel="PR Created"
closeOnSelect={!hasPullRequest ? false : true}
onSelect={() => {
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"}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem
testID="changes-commit-menu-merge-from-base"
testID="changes-menu-merge-from-base"
disabled={mergeFromBaseDisabled}
description={mergeFromBaseDisabled && hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={() => mergeFromBaseMutation.mutate()}
status={mergeFromBaseAction.status}
pendingLabel="Merging..."
successLabel="Merged"
closeOnSelect={false}
description={hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={mergeFromBaseAction.trigger}
>
Merge from {baseRefLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-commit-menu-push"
testID="changes-menu-push"
disabled={pushDisabled}
description={pushDisabled && !(gitStatus?.hasRemote ?? false) ? "No remote configured" : undefined}
onSelect={() => 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
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
) : showShipSplitButton ? (
<View style={styles.shipSplitButton}>
<Pressable
testID="changes-ship-primary"
style={[
styles.shipPrimaryButton,
shipPrimaryDisabled && styles.shipPrimaryButtonDisabled,
]}
onPress={async () => {
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 ? (
<ActivityIndicator size="small" color={theme.colors.foreground} style={styles.shipPrimarySpinner} />
) : (
<Text style={styles.shipPrimaryText}>
{resolvedShipPrimary === "merge" ? "Merge branch" : prActionLabel}
</Text>
)}
</Pressable>
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-ship-caret"
style={styles.shipCaretButton}
accessibilityRole="button"
accessibilityLabel="More ship options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260} testID="changes-ship-menu">
<DropdownMenuItem
testID="changes-ship-merge"
disabled={mergeDisabled}
onSelect={() => {
void persistShipDefault("merge");
mergeMutation.mutate();
}}
>
Merge branch
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID={hasPullRequest ? "changes-ship-view-pr" : "changes-ship-create-pr"}
disabled={prDisabled}
onSelect={() => {
void persistShipDefault("pr");
if (hasPullRequest && prStatus?.url) {
void Linking.openURL(prStatus.url);
return;
}
prMutation.mutate();
}}
>
{prActionLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
<DropdownMenu>
<DropdownMenuTrigger
@@ -998,79 +1093,36 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
{canShowShip ? (
<>
{resolvedShipPrimary === "merge" ? (
<DropdownMenuItem
testID={hasPullRequest ? "changes-menu-view-pr" : "changes-menu-create-pr"}
disabled={prDisabled}
onSelect={() => {
void persistShipDefault("pr");
if (hasPullRequest && prStatus?.url) {
void Linking.openURL(prStatus.url);
return;
}
prMutation.mutate();
}}
>
{prActionLabel}
</DropdownMenuItem>
) : (
<DropdownMenuItem
testID="changes-menu-merge"
disabled={mergeDisabled}
description={mergeDisabled && hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={() => {
void persistShipDefault("merge");
mergeMutation.mutate();
}}
>
Merge branch
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuItem
testID="changes-menu-merge-from-base"
disabled={mergeFromBaseDisabled}
description={mergeFromBaseDisabled && hasUncommittedChanges ? "Requires clean working tree" : undefined}
onSelect={() => mergeFromBaseMutation.mutate()}
>
Merge from {baseRefLabel}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-menu-push"
disabled={pushDisabled}
description={pushDisabled && !(gitStatus?.hasRemote ?? false) ? "No remote configured" : undefined}
onSelect={() => pushMutation.mutate()}
>
Push to remote
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-menu-toggle-view"
onSelect={() => setDiffModeOverride(diffMode === "uncommitted" ? "base" : "uncommitted")}
>
{diffMode === "uncommitted" ? `Show changes vs ${baseRefLabel}` : "Show uncommitted changes"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-menu-archive"
destructive
disabled={archiveDisabled}
onSelect={() => archiveMutation.mutate()}
>
Archive worktree
</DropdownMenuItem>
{gitStatus?.isPaseoOwnedWorktree ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="changes-menu-archive"
destructive
disabled={archiveDisabled}
status={archiveAction.status}
pendingLabel="Archiving..."
successLabel="Archived"
closeOnSelect={false}
onSelect={archiveAction.trigger}
>
Archive worktree
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
</View>
{isGit ? (
{isGit && hasChanges ? (
<View style={styles.diffStatusRow} testID="changes-diff-status">
<Text style={styles.diffStatusText}>
{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",

View File

@@ -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 = <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />;
} else if (isSuccess) {
leadingContent = <CheckCircle size={16} color={theme.colors.palette.green[500]} />;
} 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 (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={disabled}
disabled={isDisabled}
onPress={() => {
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 ? <Check size={16} color={theme.colors.foreground} /> : null}
</View>
) : null}
{leading ? <View style={styles.leadingSlot}>{leading}</View> : null}
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
<View style={styles.itemContent}>
<Text
numberOfLines={1}
style={[
styles.itemText,
destructive ? styles.itemTextDestructive : null,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
]}
>
{children}
{label}
</Text>
{description ? (
{description && !isPending && !isSuccess ? (
<Text
numberOfLines={2}
style={[
@@ -533,6 +573,9 @@ const styles = StyleSheet.create((theme) => ({
itemTextDestructive: {
color: theme.colors.destructive,
},
itemTextSuccess: {
color: theme.colors.palette.green[500],
},
itemTextSelectedAccent: {
color: theme.colors.accentForeground,
},

View File

@@ -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<string | null> {
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, "-");