Merge origin/main into local-streaming-speech-tts

This commit is contained in:
Mohamed Boudra
2026-02-06 13:45:31 +07:00
52 changed files with 2977 additions and 1059 deletions
+2 -16
View File
@@ -15,7 +15,7 @@ import { darkTheme } from "@/styles/theme";
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
@@ -37,6 +37,7 @@ import { getIsTauri, getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window";
import { CommandCenter } from "@/components/command-center";
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
import { queryClient } from "@/query/query-client";
polyfillCrypto();
@@ -98,21 +99,6 @@ function PushNotificationRouter() {
}
function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
@@ -222,6 +222,11 @@ export function DropdownSheet({
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const handleClose = useCallback(() => {
bottomSheetRef.current?.dismiss();
onClose();
}, [onClose]);
useEffect(() => {
if (visible) {
bottomSheetRef.current?.present();
@@ -267,6 +272,15 @@ export function DropdownSheet({
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.dropdownSheetTitle}>{title}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Close sheet"
onPress={handleClose}
hitSlop={10}
testID="dropdown-sheet-close"
>
<X size={18} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
@@ -990,11 +1004,12 @@ export function GitOptionsSection({
visible={isWorktreeSheetOpen}
onClose={() => setIsWorktreeSheetOpen(false)}
>
{worktreeOptions.map((option) => (
{worktreeOptions.map((option, index) => (
<SelectOption
key={option.path}
label={option.label}
selected={option.path === selectedWorktreePath}
testID={`worktree-attach-option-${index}`}
onPress={() => {
onSelectWorktreePath(option.path);
setIsWorktreeSheetOpen(false);
@@ -1094,6 +1109,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.palette.zinc[600],
},
bottomSheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[2],
},
@@ -84,6 +84,11 @@ export interface AgentStreamViewProps {
agent: Agent;
streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
/**
* True when we expect a history snapshot to arrive shortly (e.g. after opening an agent
* or resuming after connectivity changes) and should avoid showing the "empty" state.
*/
isSyncingHistory?: boolean;
}
export function AgentStreamView({
@@ -92,6 +97,7 @@ export function AgentStreamView({
agent,
streamItems,
pendingPermissions,
isSyncingHistory = false,
}: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles();
@@ -590,6 +596,43 @@ export function AgentStreamView({
return { marginBottom: tightGap };
}, [listHeaderComponent, tightGap]);
const listEmptyComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
const hasHeadItems = (streamHead?.length ?? 0) > 0;
if (hasPermissions || hasHeadItems) {
return null;
}
const shouldShowSyncing =
isSyncingHistory || agent.status === "running";
if (shouldShowSyncing) {
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={stylesheet.emptyStateText}>Catching up…</Text>
</View>
);
}
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
);
}, [
agent.status,
isSyncingHistory,
pendingPermissionItems.length,
streamHead,
theme.colors.foregroundMuted,
]);
return (
<ToolCallSheetProvider>
<View style={stylesheet.container}>
@@ -607,13 +650,7 @@ export function AgentStreamView({
style={stylesheet.list}
onScroll={handleScroll}
scrollEventThrottle={16}
ListEmptyComponent={
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
}
ListEmptyComponent={listEmptyComponent}
ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData}
maintainVisibleContentPosition={
@@ -1071,6 +1108,7 @@ function PermissionRequestCard({
) : null}
<Text
testID="permission-request-question"
style={[
permissionStyles.question,
{ color: theme.colors.mutedForeground },
@@ -1086,6 +1124,7 @@ function PermissionRequestCard({
]}
>
<Pressable
testID="permission-request-deny"
style={(state) => {
const hovered = Boolean((state as any).hovered);
const pressed = Boolean(state.pressed);
@@ -1126,6 +1165,7 @@ function PermissionRequestCard({
</Pressable>
<Pressable
testID="permission-request-accept"
style={(state) => {
const hovered = Boolean((state as any).hovered);
const pressed = Boolean(state.pressed);
@@ -263,37 +263,33 @@ function SidebarContent({
const { status } = useCheckoutStatusQuery({ serverId, 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}>
{isGit ? (
<Pressable
style={[styles.tab, effectiveTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
effectiveTab === "changes" && styles.tabTextActive,
]}
>
Changes
</Text>
</Pressable>
) : null}
<Pressable
style={[styles.tab, effectiveTab === "files" && styles.tabActive]}
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
!isGit && styles.tabTextMuted,
]}
>
Changes
</Text>
</Pressable>
<Pressable
style={[styles.tab, activeTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
effectiveTab === "files" && styles.tabTextActive,
activeTab === "files" && styles.tabTextActive,
]}
>
Files
@@ -311,10 +307,10 @@ function SidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{effectiveTab === "changes" && (
{activeTab === "changes" && (
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
)}
{effectiveTab === "files" && (
{activeTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
</View>
@@ -389,6 +385,9 @@ const styles = StyleSheet.create((theme) => ({
tabTextActive: {
color: theme.colors.foreground,
},
tabTextMuted: {
opacity: 0.8,
},
headerRightSection: {
flexDirection: "row",
alignItems: "center",
@@ -36,7 +36,11 @@ import {
MoreVertical,
X,
} from "lucide-react-native";
import type { ExplorerEntry, ExplorerFile } from "@/stores/session-store";
import type {
AgentFileExplorerState,
ExplorerEntry,
ExplorerFile,
} from "@/stores/session-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store";
@@ -312,6 +316,11 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const showInitialLoading =
!directories.has(".") &&
Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === ".");
const showBackFromError = Boolean(error && selectedEntryPath);
const errorRecoveryPath = useMemo(
() => getErrorRecoveryPath(explorerState),
[explorerState]
);
const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath);
const minTreeWidth = 220;
@@ -475,6 +484,17 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
[]
);
const handleBackFromError = useCallback(() => {
if (!agentId || !requestDirectoryListing) {
return;
}
selectExplorerEntry(agentId, null);
requestDirectoryListing(agentId, errorRecoveryPath, {
recordHistory: false,
setCurrentPath: true,
});
}, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]);
if (!agentExists) {
return (
<View style={styles.centerState}>
@@ -491,19 +511,26 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
<Pressable
style={styles.retryButton}
onPress={() => {
if (agentId) {
requestDirectoryListing(agentId, ".", {
recordHistory: false,
setCurrentPath: false,
});
}
}}
>
<Text style={styles.retryButtonText}>Retry</Text>
</Pressable>
<View style={styles.errorActions}>
{showBackFromError ? (
<Pressable style={styles.retryButton} onPress={handleBackFromError}>
<Text style={styles.retryButtonText}>Back</Text>
</Pressable>
) : null}
<Pressable
style={styles.retryButton}
onPress={() => {
if (agentId) {
requestDirectoryListing(agentId, ".", {
recordHistory: false,
setCurrentPath: false,
});
}
}}
>
<Text style={styles.retryButtonText}>Retry</Text>
</Pressable>
</View>
</View>
) : showInitialLoading ? (
<View style={styles.centerState}>
@@ -960,6 +987,21 @@ function getAncestorDirectories(directory: string): string[] {
return ancestors;
}
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
if (!state) {
return ".";
}
const currentHistoryPath =
state.history.length > 0 ? state.history[state.history.length - 1] : null;
const candidate = currentHistoryPath ?? state.lastVisitedPath ?? state.currentPath;
if (!candidate || candidate.length === 0) {
return ".";
}
return candidate;
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -1065,6 +1107,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
errorActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
+108 -226
View File
@@ -1,5 +1,4 @@
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,
@@ -17,8 +16,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Linking from "expo-linking";
import { Archive, ChevronDown, ChevronRight, GitBranch, MoreVertical, ArrowLeftRight, ListChevronsDownUp, ListChevronsUpDown } from "lucide-react-native";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import {
useCheckoutDiffQuery,
type ParsedDiffFile,
@@ -40,20 +38,6 @@ import {
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;
};
// =============================================================================
// Git Actions Data Structure
// =============================================================================
@@ -85,47 +69,6 @@ interface GitActions {
menu: GitAction[];
}
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");
@@ -480,10 +423,6 @@ type GitDiffSection = {
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const router = useRouter();
const queryClient = useQueryClient();
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
@@ -669,157 +608,98 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
});
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
const commitMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutCommit(cwd, { addAll: true });
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshDiff();
void refreshStatus();
},
onError: (err) => {
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" })
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" })
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const handleCommit = useCallback(() => {
setActionError(null);
void runCommit({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to commit";
setActionError(message);
},
});
});
}, [runCommit, serverId, cwd]);
const prMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutPrCreate(cwd, {});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshPrStatus();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
},
});
const mergeMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutMerge(cwd, {
baseRef,
strategy: "merge",
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshDiff();
void refreshStatus();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
},
});
const mergeFromBaseMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutMergeFromBase(cwd, {
baseRef,
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshDiff();
void refreshStatus();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
},
});
const pushMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutPush(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshStatus();
},
onError: (err) => {
const handlePush = useCallback(() => {
setActionError(null);
void runPush({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to push";
setActionError(message);
},
});
});
}, [runPush, serverId, cwd]);
const archiveMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const worktreePath = status?.cwd;
if (!worktreePath) {
throw new Error("Worktree path unavailable");
}
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
router.replace("/agent" as any);
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
setActionError(null);
void runCreatePr({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
},
});
});
}, [persistShipDefault, runCreatePr, serverId, cwd]);
// 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 handleMergeBranch = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
setActionError(null);
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
});
}, [baseRef, runMergeFromBase, serverId, cwd]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
setActionError("Worktree path unavailable");
return;
}
setActionError(null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace("/agent" as any);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
});
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]);
const renderFileBody: SectionListRenderItem<ParsedDiffFile, GitDiffSection> = useCallback(
({ item, section }) => (
@@ -861,16 +741,17 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}, [baseRef]);
const commitDisabled = actionsDisabled || commitMutation.isPending;
const prDisabled = actionsDisabled || prMutation.isPending;
const mergeDisabled = actionsDisabled || mergeMutation.isPending || hasUncommittedChanges;
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
const mergeFromBaseDisabled =
actionsDisabled || mergeFromBaseMutation.isPending || hasUncommittedChanges;
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
const pushDisabled =
actionsDisabled || pushMutation.isPending || !(gitStatus?.hasRemote ?? false);
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
const archiveDisabled =
actionsDisabled ||
archiveMutation.isPending ||
archiveStatus === "pending" ||
!gitStatus?.isPaseoOwnedWorktree;
let bodyContent: ReactElement;
@@ -966,8 +847,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: commitDisabled,
status: commitAction.status,
handler: commitAction.trigger,
status: commitStatus,
handler: handleCommit,
});
// Push - when has remote
@@ -978,9 +859,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: pushDisabled,
status: pushAction.status,
status: pushStatus,
description: !hasRemote ? "No remote configured" : undefined,
handler: pushAction.trigger,
handler: handlePush,
});
}
@@ -1006,8 +887,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateAction.status,
handler: prCreateAction.trigger,
status: prCreateStatus,
handler: handleCreatePr,
});
}
@@ -1019,9 +900,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
status: mergeAction.status,
status: mergeStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeAction.trigger,
handler: handleMergeBranch,
});
}
@@ -1033,9 +914,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseAction.status,
status: mergeFromBaseStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeFromBaseAction.trigger,
handler: handleMergeFromBase,
});
}
@@ -1047,9 +928,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: archiveDisabled,
status: archiveAction.status,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: archiveAction.trigger,
handler: handleArchiveWorktree,
});
}
@@ -1061,7 +942,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
primaryActionId = "commit";
}
// Rule 2: Ahead of origin → Push
else if (aheadOfOrigin > 0 && allActions.has("push") && !pushDisabled) {
else if (aheadOfOrigin > 0 && allActions.has("push")) {
primaryActionId = "push";
}
// Rule 3: Has PR → View PR
@@ -1103,7 +984,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitAction, pushAction, prCreateAction, mergeAction, mergeFromBaseAction, archiveAction,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
]);
// Helper to get display label based on status
@@ -1216,7 +1098,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
) : null}
</View>
{isGit && (hasUncommittedChanges || aheadCount > 0) ? (
{isGit ? (
<View style={styles.diffStatusContainer}>
<View style={styles.diffStatusInner}>
<Pressable
+194 -104
View File
@@ -12,6 +12,7 @@ import {
useMemo,
useState,
useEffect,
useRef,
type ReactElement,
type MutableRefObject,
} from "react";
@@ -24,7 +25,6 @@ import {
DraggableList,
type DraggableRenderItemInfo,
} from "./draggable-list";
import { formatTimeAgo } from "@/utils/time";
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
@@ -185,6 +185,164 @@ function SectionHeader({
);
}
interface GroupedAgentRowProps {
agent: AggregatedAgent;
isSelected: boolean;
shortcutNumber: number | null;
onPress: () => void;
onLongPress: () => void;
onArchive: (e: { stopPropagation: () => void }) => void;
}
function GroupedAgentRow({
agent,
isSelected,
shortcutNumber,
onPress,
onLongPress,
onArchive,
}: GroupedAgentRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const [isArchiveConfirmVisible, setIsArchiveConfirmVisible] = useState(false);
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearHoverOutTimeout = useCallback(() => {
if (!hoverOutTimeoutRef.current) {
return;
}
clearTimeout(hoverOutTimeoutRef.current);
hoverOutTimeoutRef.current = null;
}, []);
useEffect(() => {
return () => clearHoverOutTimeout();
}, [clearHoverOutTimeout]);
const handleHoverIn = useCallback(() => {
clearHoverOutTimeout();
setIsHovered(true);
}, [clearHoverOutTimeout]);
const handleHoverOut = useCallback(() => {
clearHoverOutTimeout();
hoverOutTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
setIsArchiveConfirmVisible(false);
}, 50);
}, [clearHoverOutTimeout]);
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const activeBranchLabel = checkout?.isGit
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
? checkout.currentBranch
: null) ??
checkout.baseRef ??
"git")
: null;
const canArchive = agent.status !== "running" && !agent.requiresAttention;
const showArchive = canArchive && shortcutNumber === null && (isHovered || isArchiveConfirmVisible);
return (
<Pressable
style={({ pressed }) => [
styles.agentItem,
!isSelected && styles.agentItemUnselected,
isSelected && styles.agentItemSelected,
isHovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={onPress}
onPressIn={() => {
if (Platform.OS !== "web") {
return;
}
handleHoverIn();
}}
onLongPress={onLongPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || isHovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
{shortcutNumber !== null ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{shortcutNumber}
</Text>
</View>
) : showArchive ? (
<Pressable
style={[
styles.branchBadge,
isArchiveConfirmVisible && styles.archiveConfirmBadge,
]}
onPress={(e) => {
e.stopPropagation();
if (!isArchiveConfirmVisible) {
setIsArchiveConfirmVisible(true);
return;
}
onArchive(e);
setIsArchiveConfirmVisible(false);
}}
testID={
isArchiveConfirmVisible
? `agent-archive-confirm-${agent.serverId}-${agent.id}`
: `agent-archive-${agent.serverId}-${agent.id}`
}
>
{({ hovered: archiveHovered }) =>
isArchiveConfirmVisible ? (
<Text
style={[
styles.branchBadgeText,
styles.archiveConfirmText,
archiveHovered && styles.archiveConfirmTextHovered,
]}
numberOfLines={1}
>
Confirm
</Text>
) : (
<Archive
size={12}
color={
archiveHovered ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)
}
</Pressable>
) : activeBranchLabel ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{activeBranchLabel}
</Text>
</View>
) : null}
</View>
</View>
</Pressable>
);
}
export function GroupedAgentList({
agents,
isRefreshing = false,
@@ -337,107 +495,6 @@ export function GroupedAgentList({
[]
);
const AgentListRow = useCallback(
({ agent }: { agent: AggregatedAgent }) => {
const [isHovered, setIsHovered] = useState(false);
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const isRunning = agent.status === "running";
const shortcutNumber =
showShortcutBadges ? (shortcutIndexByAgentKey.get(agentKey) ?? null) : null;
const checkoutQuery = useCheckoutStatusCacheOnly({
serverId: agent.serverId,
cwd: agent.cwd,
});
const checkout = checkoutQuery.data ?? null;
const activeBranchLabel = checkout?.isGit
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
? checkout.currentBranch
: null) ??
checkout.baseRef ??
"git")
: null;
const canArchive = !isRunning && !agent.requiresAttention;
return (
<Pressable
style={({ pressed }) => [
styles.agentItem,
!isSelected && styles.agentItemUnselected,
isSelected && styles.agentItemSelected,
isHovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
]}
onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
<View style={styles.agentContent}>
<View style={styles.row}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[
styles.agentTitle,
(isSelected || isHovered) && styles.agentTitleHighlighted,
]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
{shortcutNumber !== null ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{shortcutNumber}
</Text>
</View>
) : isHovered && canArchive ? (
<Pressable
style={styles.branchBadge}
onPress={(e) => handleArchiveAgent(e, agent)}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(true)}
testID={`agent-archive-${agent.serverId}-${agent.id}`}
>
{({ hovered: archiveHovered }) => (
<Archive
size={12}
color={
archiveHovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
) : activeBranchLabel ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{activeBranchLabel}
</Text>
</View>
) : null}
</View>
</View>
</Pressable>
);
},
[
handleAgentLongPress,
handleAgentPress,
handleArchiveAgent,
selectedAgentId,
showShortcutBadges,
shortcutIndexByAgentKey,
theme.colors.foreground,
theme.colors.foregroundMuted,
]
);
const renderSection = useCallback(
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
@@ -454,12 +511,34 @@ export function GroupedAgentList({
/>
{!isCollapsed &&
section.agents.map((agent) => (
<AgentListRow key={`${agent.serverId}:${agent.id}`} agent={agent} />
<GroupedAgentRow
key={`${agent.serverId}:${agent.id}`}
agent={agent}
isSelected={selectedAgentId === `${agent.serverId}:${agent.id}`}
shortcutNumber={
showShortcutBadges
? (shortcutIndexByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null)
: null
}
onPress={() => handleAgentPress(agent.serverId, agent.id)}
onLongPress={() => handleAgentLongPress(agent)}
onArchive={(e) => handleArchiveAgent(e, agent)}
/>
))}
</View>
);
},
[AgentListRow, collapsedProjectKeys, handleCreateAgentInProject, toggleProjectCollapsed]
[
collapsedProjectKeys,
handleAgentLongPress,
handleAgentPress,
handleArchiveAgent,
handleCreateAgentInProject,
selectedAgentId,
showShortcutBadges,
shortcutIndexByAgentKey,
toggleProjectCollapsed,
]
);
const keyExtractor = useCallback(
@@ -675,6 +754,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
lineHeight: 12,
},
archiveConfirmBadge: {
minWidth: 76,
maxWidth: 9999,
flexShrink: 0,
},
archiveConfirmText: {
color: theme.colors.foreground,
},
archiveConfirmTextHovered: {
opacity: 0.9,
},
agentTitleHighlighted: {
color: theme.colors.foreground,
opacity: 1,
@@ -0,0 +1,79 @@
import type { PropsWithChildren, ReactElement } from "react";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
export function HeaderToggleButton({
onPress,
tooltipLabel,
tooltipKeys,
tooltipSide,
tooltipDelayDuration = 0,
style,
disabled,
children,
...props
}: PropsWithChildren<
Omit<PressableProps, "style" | "onPress"> & {
onPress: NonNullable<PressableProps["onPress"]>;
tooltipLabel: string;
tooltipKeys: ShortcutKey[];
tooltipSide: "left" | "right" | "top" | "bottom";
tooltipDelayDuration?: number;
style?: StyleProp<ViewStyle>;
}
>): ReactElement {
const tooltipTestID =
typeof props.testID === "string" && props.testID.length > 0
? `${props.testID}-tooltip`
: undefined;
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
{...props}
disabled={disabled}
onPress={(e) => {
onPress(e);
}}
style={[styles.button, style]}
>
{children}
</TooltipTrigger>
<TooltipContent testID={tooltipTestID} side={tooltipSide} align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
<Shortcut keys={tooltipKeys} style={styles.shortcut} textStyle={styles.shortcutText} />
</View>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
button: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
shortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
shortcutText: {
color: theme.colors.popoverForeground,
},
}));
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { Text } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Menu, PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
interface MenuHeaderProps {
@@ -28,19 +29,20 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
<ScreenHeader
left={
<>
<Pressable
<HeaderToggleButton
onPress={toggleAgentList}
style={styles.menuButton}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
collapsable={false}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</Pressable>
</HeaderToggleButton>
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
@@ -58,13 +60,6 @@ const styles = StyleSheet.create((theme) => ({
left: {
gap: theme.spacing[2],
},
menuButton: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
title: {
flex: 1,
fontSize: theme.fontSize.base,
@@ -0,0 +1,37 @@
import type { ReactElement } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform";
export function Shortcut({
keys,
style,
textStyle,
}: {
keys: ShortcutKey[];
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}): ReactElement {
return (
<View style={[styles.root, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[1],
paddingVertical: 2,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));
+477
View File
@@ -0,0 +1,477 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
} from "react";
import {
Dimensions,
Platform,
Modal,
Pressable,
StatusBar,
View,
type PressableProps,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
interface Rect {
x: number;
y: number;
width: number;
height: number;
}
type TooltipContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
delayDuration: number;
};
const TooltipContext = createContext<TooltipContextValue | null>(null);
function useTooltipContext(componentName: string): TooltipContextValue {
const ctx = useContext(TooltipContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <Tooltip />`);
}
return ctx;
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? Boolean(open) : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
return [value, setValue];
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computePosition({
triggerRect,
contentSize,
displayArea,
side,
align,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
side: Side;
align: Align;
offset: number;
}): { x: number; y: number; actualSide: Side } {
const { width: contentWidth, height: contentHeight } = contentSize;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom =
displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight =
displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
let actualSide = side;
if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
actualSide = "top";
} else if (side === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
actualSide = "bottom";
} else if (side === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
actualSide = "right";
} else if (side === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
actualSide = "left";
}
let x = 0;
let y = 0;
if (actualSide === "bottom") {
y = triggerRect.y + triggerRect.height + offset;
if (align === "start") {
x = triggerRect.x;
} else if (align === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
} else if (actualSide === "top") {
y = triggerRect.y - contentHeight - offset;
if (align === "start") {
x = triggerRect.x;
} else if (align === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
} else if (actualSide === "left") {
x = triggerRect.x - contentWidth - offset;
if (align === "start") {
y = triggerRect.y;
} else if (align === "end") {
y = triggerRect.y + triggerRect.height - contentHeight;
} else {
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
}
} else {
x = triggerRect.x + triggerRect.width + offset;
if (align === "start") {
y = triggerRect.y;
} else if (align === "end") {
y = triggerRect.y + triggerRect.height - contentHeight;
} else {
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
}
}
const padding = 8;
x = Math.max(
padding,
Math.min(displayArea.width - contentWidth - padding, x)
);
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y)
);
return { x, y, actualSide };
}
export function Tooltip({
open,
defaultOpen,
onOpenChange,
delayDuration = 0,
enabledOnDesktop = true,
enabledOnMobile = false,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
delayDuration?: number;
enabledOnDesktop?: boolean;
enabledOnMobile?: boolean;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const isWeb = Platform.OS === "web";
const isMobileWeb =
isWeb &&
typeof navigator !== "undefined" &&
/Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent ?? "");
const enabled = isWeb ? (isMobileWeb ? enabledOnMobile : enabledOnDesktop) : enabledOnMobile;
const value = useMemo<TooltipContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
enabled,
delayDuration,
}),
[isOpen, setIsOpen, enabled, delayDuration]
);
return <TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>;
}
export function TooltipTrigger({
children,
disabled,
onHoverIn,
onHoverOut,
onFocus,
onBlur,
onPress,
...props
}: PropsWithChildren<PressableProps>): ReactElement {
const ctx = useTooltipContext("TooltipTrigger");
const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearOpenTimer = useCallback(() => {
if (openTimerRef.current) {
clearTimeout(openTimerRef.current);
openTimerRef.current = null;
}
}, []);
const scheduleOpen = useCallback(() => {
if (!ctx.enabled || disabled) return;
clearOpenTimer();
if (ctx.delayDuration <= 0) {
ctx.setOpen(true);
return;
}
openTimerRef.current = setTimeout(() => {
ctx.setOpen(true);
openTimerRef.current = null;
}, ctx.delayDuration);
}, [clearOpenTimer, ctx, disabled]);
const close = useCallback(() => {
clearOpenTimer();
ctx.setOpen(false);
}, [clearOpenTimer, ctx]);
useEffect(() => {
return () => {
clearOpenTimer();
};
}, [clearOpenTimer]);
const handleHoverIn = useCallback(
(e?: any) => {
onHoverIn?.(e);
scheduleOpen();
},
[onHoverIn, scheduleOpen]
);
const handleHoverOut = useCallback(
(e?: any) => {
onHoverOut?.(e);
close();
},
[onHoverOut, close]
);
return (
<Pressable
{...props}
ref={ctx.triggerRef}
collapsable={false}
disabled={disabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
onFocus={(e) => {
onFocus?.(e);
if (!ctx.enabled || disabled) return;
clearOpenTimer();
ctx.setOpen(true);
}}
onBlur={(e) => {
onBlur?.(e);
close();
}}
onPress={(e) => {
onPress?.(e);
close();
}}
{...(Platform.OS === "web"
? ({
// RN Web's hover handling can vary across environments; pointer events are the most reliable.
onPointerEnter: handleHoverIn,
onPointerLeave: handleHoverOut,
onMouseEnter: handleHoverIn,
onMouseLeave: handleHoverOut,
} as any)
: null)}
>
{children}
</Pressable>
);
}
export function TooltipContent({
children,
side = "top",
align = "center",
offset = 6,
style,
testID,
maxWidth = 280,
}: PropsWithChildren<{
side?: Side;
align?: Align;
offset?: number;
style?: StyleProp<ViewStyle>;
testID?: string;
maxWidth?: number;
}>): ReactElement | null {
const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(
null
);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
useEffect(() => {
if (!ctx.open || !ctx.enabled || !ctx.triggerRef.current) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return;
}
const statusBarHeight =
Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
measureElement(ctx.triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
});
return () => {
cancelled = true;
};
}, [ctx.enabled, ctx.open, ctx.triggerRef]);
useEffect(() => {
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight };
const result = computePosition({
triggerRect,
contentSize,
displayArea,
side,
align,
offset,
});
setPosition({ x: result.x, y: result.y });
}, [triggerRect, contentSize, side, align, offset]);
const handleLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize({ width, height });
},
[]
);
if (!ctx.open || !ctx.enabled) return null;
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops.
if (Platform.OS === "web") {
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
{children}
</Animated.View>
</View>
</Portal>
);
}
return (
<Modal
visible={ctx.open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)}
>
<View pointerEvents="box-none" style={styles.overlay}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
>
{children}
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: { flex: 1 },
portalOverlay: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
},
content: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.popover,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOpacity: 0.2,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 6,
zIndex: 1000,
},
}));
+4 -43
View File
@@ -915,16 +915,6 @@ export function SessionProvider({
});
}
setInitializingAgents(serverId, (prev) => {
const currentState = prev.get(agentId);
if (currentState === false) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
// NOTE: We don't update lastActivityAt on every stream event to prevent
// cascading rerenders. The agent_update handler updates agent.lastActivityAt
// on status changes, which is sufficient for sorting and display purposes.
@@ -956,7 +946,7 @@ export function SessionProvider({
clearAgentStreamHead(serverId, agentId);
setInitializingAgents(serverId, (prev) => {
if (!prev.has(agentId)) {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
@@ -990,16 +980,6 @@ export function SessionProvider({
requestId: (message.payload as any).requestId,
});
}
if (status === "agent_initialized" && "agentId" in message.payload) {
const agentId = (message.payload as any).agentId as string | undefined;
if (agentId) {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
}
}
});
const unsubPermissionRequest = client.on(
@@ -1443,12 +1423,6 @@ export function SessionProvider({
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
if (!client) {
console.warn("[Session] initializeAgent skipped: daemon unavailable");
setInitializingAgents(serverId, (prev) => {
@@ -1470,24 +1444,17 @@ export function SessionProvider({
});
});
},
[serverId, client, setAgentStreamTail, setInitializingAgents, clearAgentStreamHead]
[serverId, client, setInitializingAgents]
);
const refreshAgent = useCallback(
({ agentId, requestId }: { agentId: string; requestId?: string }) => {
({ agentId }: { agentId: string }) => {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
refreshAgentMutation
.mutateAsync({ agentId })
.catch((error) => {
@@ -1499,13 +1466,7 @@ export function SessionProvider({
});
});
},
[
serverId,
refreshAgentMutation,
setAgentStreamTail,
setInitializingAgents,
clearAgentStreamHead,
]
[serverId, refreshAgentMutation, setInitializingAgents]
);
const sendAgentMessage = useCallback(
@@ -1,19 +1,18 @@
import { useCallback } from "react";
import { useSessionStore } from "@/stores/session-store";
import {
attachInitTimeout,
createInitDeferred,
getInitDeferred,
getInitKey,
rejectInitDeferred,
} from "@/utils/agent-initialization";
const INIT_TIMEOUT_MS = 10000;
const INIT_TIMEOUT_MS = 5 * 60_000;
export function useAgentInitialization(serverId: string) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const ensureAgentIsInitialized = useCallback(
(agentId: string): Promise<void> => {
@@ -24,10 +23,23 @@ export function useAgentInitialization(serverId: string) {
}
const deferred = createInitDeferred(key);
const timeoutId = setTimeout(() => {
rejectInitDeferred(key, new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`));
setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
rejectInitDeferred(
key,
new Error(
`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`
)
);
}, INIT_TIMEOUT_MS);
attachInitTimeout(key, timeoutId);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
@@ -35,15 +47,7 @@ export function useAgentInitialization(serverId: string) {
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
if (!client) {
clearTimeout(timeoutId);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
@@ -56,10 +60,10 @@ export function useAgentInitialization(serverId: string) {
client
.initializeAgent(agentId)
.then(() => {
clearTimeout(timeoutId);
// No-op: the actual "timeline hydrated" signal is the `agent_stream_snapshot`
// message, handled in SessionContext.
})
.catch((error) => {
clearTimeout(timeoutId);
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
@@ -73,7 +77,7 @@ export function useAgentInitialization(serverId: string) {
return deferred.promise;
},
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
[client, serverId, setInitializingAgents]
);
const refreshAgent = useCallback(
@@ -87,13 +91,6 @@ export function useAgentInitialization(serverId: string) {
return next;
});
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
try {
await client.refreshAgent(agentId);
} catch (error) {
@@ -105,9 +102,8 @@ export function useAgentInitialization(serverId: string) {
throw error;
}
},
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents]
[client, serverId, setInitializingAgents]
);
return { ensureAgentIsInitialized, refreshAgent };
}
@@ -55,12 +55,13 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
appVisibleRef.current = nextState === "active";
if (nextState === "active") {
recordUserActivity();
maybeSendImmediateHeartbeat();
}
// Send immediately on visibility changes so the server can adapt streaming behavior.
sendHeartbeat();
});
return () => subscription.remove();
}, [maybeSendImmediateHeartbeat, recordUserActivity]);
}, [recordUserActivity, sendHeartbeat]);
// Track user activity on web for accurate staleness.
useEffect(() => {
+14
View File
@@ -0,0 +1,14 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
},
});
@@ -7,6 +7,7 @@ import {
ScrollView,
Platform,
BackHandler,
AppState,
} from "react-native";
import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
@@ -31,6 +32,7 @@ import {
} from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { BackHeader } from "@/components/headers/back-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentDetailsSheet } from "@/components/agent-details-sheet";
@@ -502,6 +504,46 @@ function AgentScreenContent({
});
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected]);
// When the app comes back to the foreground, re-sync history for the focused agent.
// This covers cases where the OS/backgrounding caused us to miss stream events.
const lastAppStateRef = useRef(AppState.currentState);
const lastResumeSyncAtRef = useRef(0);
useEffect(() => {
if (!resolvedAgentId) {
return;
}
const subscription = AppState.addEventListener("change", (nextState) => {
const prev = lastAppStateRef.current;
lastAppStateRef.current = nextState;
if (nextState !== "active" || prev === "active") {
return;
}
if (!isConnected) {
return;
}
const now = Date.now();
// Avoid accidental double-syncs on rapid transitions.
if (now - lastResumeSyncAtRef.current < 2000) {
return;
}
lastResumeSyncAtRef.current = now;
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] Agent initialization failed on resume", {
agentId: resolvedAgentId,
error,
});
});
});
return () => {
subscription.remove();
};
}, [ensureAgentIsInitialized, isConnected, resolvedAgentId]);
useEffect(() => {
if (Platform.OS !== "web") {
return;
@@ -569,7 +611,17 @@ function AgentScreenContent({
title={effectiveAgent.title || "Agent"}
rightContent={
<View style={styles.headerRightContent}>
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
<HeaderToggleButton
onPress={toggleFileExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.menuButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{isMobile ? (
checkout?.isGit ? (
<GitBranch
@@ -600,7 +652,7 @@ function AgentScreenContent({
}
/>
)}
</Pressable>
</HeaderToggleButton>
<DropdownMenu
onOpenChange={(open) => {
if (open && agent?.cwd) {
@@ -712,28 +764,21 @@ function AgentScreenContent({
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
{isInitializing && !shouldUseOptimisticStream ? (
<View style={styles.loadingContainer}>
<ActivityIndicator
size="large"
color={theme.colors.primary}
/>
<Text style={styles.loadingText}>Loading agent...</Text>
</View>
) : (
<AgentStreamView
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
/>
)}
<AgentStreamView
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={
shouldUseOptimisticStream ? mergedStreamItems : streamItems
}
pendingPermissions={pendingPermissions}
isSyncingHistory={isInitializing && !shouldUseOptimisticStream}
/>
</ReanimatedAnimated.View>
</View>
{/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && (
{agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
)}
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("checkout-git-actions-store", () => {
const serverId = "server-1";
const cwd = "/tmp/repo";
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
});
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
});
it("shares pending state per checkout and de-dupes in-flight calls", async () => {
const deferred = createDeferred<any>();
const client = {
checkoutCommit: vi.fn(() => deferred.promise),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...(state.sessions as any),
[serverId]: { client } as any,
},
}));
const store = useCheckoutGitActionsStore.getState();
const first = store.commit({ serverId, cwd });
const second = store.commit({ serverId, cwd });
expect(client.checkoutCommit).toHaveBeenCalledTimes(1);
expect(
store.getStatus({ serverId, cwd, actionId: "commit" })
).toBe("pending");
deferred.resolve({});
await Promise.all([first, second]);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success");
vi.advanceTimersByTime(1000);
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
});
});
@@ -0,0 +1,283 @@
import { create } from "zustand";
import { useSessionStore } from "@/stores/session-store";
import { queryClient } from "@/query/query-client";
const SUCCESS_DISPLAY_MS = 1000;
export type CheckoutGitActionStatus = "idle" | "pending" | "success";
export type CheckoutGitAsyncActionId =
| "commit"
| "push"
| "create-pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
function checkoutKey(serverId: string, cwd: string): CheckoutKey {
return `${serverId}::${cwd}`;
}
function resolveClient(serverId: string) {
const session = useSessionStore.getState().sessions[serverId];
const client = session?.client ?? null;
if (!client) {
throw new Error("Daemon client unavailable");
}
return client;
}
function setStatus(
key: CheckoutKey,
actionId: CheckoutGitAsyncActionId,
status: CheckoutGitActionStatus
) {
useCheckoutGitActionsStore.setState((state) => {
const current = state.statusByCheckout[key]?.[actionId] ?? "idle";
if (current === status) {
return state;
}
return {
...state,
statusByCheckout: {
...state.statusByCheckout,
[key]: {
...(state.statusByCheckout[key] ?? {}),
[actionId]: status,
},
},
};
});
}
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
void queryClient.invalidateQueries({
queryKey: ["checkoutStatus", serverId, cwd],
});
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
key[0] === "checkoutDiff" &&
key[1] === serverId &&
key[2] === cwd
);
},
});
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
key[0] === "checkoutPrStatus" &&
key[1] === serverId &&
key[2] === cwd
);
},
});
}
function invalidateWorktreeList() {
void queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
}
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
const inFlight = new Map<string, Promise<unknown>>();
function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): string {
return `${key}::${actionId}`;
}
interface CheckoutGitActionsStoreState {
statusByCheckout: Record<CheckoutKey, StatusMap>;
getStatus: (params: {
serverId: string;
cwd: string;
actionId: CheckoutGitAsyncActionId;
}) => CheckoutGitActionStatus;
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: {
serverId: string;
cwd: string;
worktreePath: string;
}) => Promise<void>;
}
async function runCheckoutAction({
serverId,
cwd,
actionId,
run,
}: {
serverId: string;
cwd: string;
actionId: CheckoutGitAsyncActionId;
run: () => Promise<void>;
}): Promise<void> {
const key = checkoutKey(serverId, cwd);
const inflightId = inFlightKey(key, actionId);
const existing = inFlight.get(inflightId);
if (existing) {
await existing;
return;
}
const prevTimer = successTimers.get(inflightId);
if (prevTimer) {
clearTimeout(prevTimer);
successTimers.delete(inflightId);
}
setStatus(key, actionId, "pending");
const promise = (async () => {
try {
await run();
invalidateCheckoutGitQueries(serverId, cwd);
setStatus(key, actionId, "success");
const timer = setTimeout(() => {
setStatus(key, actionId, "idle");
successTimers.delete(inflightId);
}, SUCCESS_DISPLAY_MS);
successTimers.set(inflightId, timer);
} catch (error) {
setStatus(key, actionId, "idle");
throw error;
} finally {
inFlight.delete(inflightId);
}
})();
inFlight.set(inflightId, promise);
await promise;
}
export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({
statusByCheckout: {},
getStatus: ({ serverId, cwd, actionId }) => {
const key = checkoutKey(serverId, cwd);
return get().statusByCheckout[key]?.[actionId] ?? "idle";
},
commit: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "commit",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutCommit(cwd, { addAll: true });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
push: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "push",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPush(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
createPr: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "create-pr",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPrCreate(cwd, {});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "merge-branch",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutMerge(cwd, {
baseRef,
strategy: "merge",
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeFromBase: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "merge-from-base",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutMergeFromBase(cwd, {
baseRef,
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
archiveWorktree: async ({ serverId, cwd, worktreePath }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "archive-worktree",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
invalidateWorktreeList();
},
});
},
}));
export function __resetCheckoutGitActionsStoreForTests() {
for (const timer of successTimers.values()) {
clearTimeout(timer);
}
successTimers.clear();
inFlight.clear();
useCheckoutGitActionsStore.setState({ statusByCheckout: {} });
}
+5 -19
View File
@@ -46,25 +46,11 @@ function createUniqueTimelineId(
timestamp: Date
): string {
const base = createTimelineId(prefix, text, timestamp);
let suffixSeed = state.length;
let candidate = `${base}_${suffixSeed.toString(36)}`;
// Fast path when the generated id hasn't been used yet
const hasCollision = state.some((entry) => entry.id === candidate);
if (!hasCollision) {
return candidate;
}
// If the id already exists (e.g. prior items were pruned/replaced),
// spin until we find an unused suffix. Building the lookup Set lazily keeps
// the common case cheap while still guaranteeing uniqueness.
const usedIds = new Set(state.map((entry) => entry.id));
while (usedIds.has(candidate)) {
suffixSeed += 1;
candidate = `${base}_${suffixSeed.toString(36)}`;
}
return candidate;
// We only ever append new timeline items, and we incorporate the current
// length as a monotonic suffix, so uniqueness is guaranteed without an O(n)
// collision scan (important for large hydration snapshots).
const suffixSeed = state.length;
return `${base}_${suffixSeed.toString(36)}`;
}
export type StreamItem =
+22 -3
View File
@@ -2,6 +2,7 @@ export interface DeferredInit {
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null;
}
const initPromises = new Map<string, DeferredInit>();
@@ -23,14 +24,30 @@ export function createInitDeferred(key: string): DeferredInit {
reject = rej;
});
const deferred: DeferredInit = { promise, resolve, reject };
const deferred: DeferredInit = { promise, resolve, reject, timeoutId: null };
initPromises.set(key, deferred);
return deferred;
}
export function attachInitTimeout(key: string, timeoutId: ReturnType<typeof setTimeout>): void {
const deferred = initPromises.get(key);
if (!deferred) {
clearTimeout(timeoutId);
return;
}
deferred.timeoutId = timeoutId;
}
export function resolveInitDeferred(key: string): void {
const deferred = initPromises.get(key);
deferred?.resolve();
if (!deferred) {
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key);
deferred.resolve();
}
export function rejectInitDeferred(key: string, error: Error): void {
@@ -38,7 +55,9 @@ export function rejectInitDeferred(key: string, error: Error): void {
if (!deferred) {
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key);
deferred.reject(error);
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatShortcut } from "./format-shortcut";
describe("formatShortcut", () => {
it("uses symbols on macOS", () => {
expect(formatShortcut(["mod", "B"], "mac")).toBe("⌘B");
expect(formatShortcut(["mod", "E"], "mac")).toBe("⌘E");
});
it("uses Ctrl+ on non-mac platforms", () => {
expect(formatShortcut(["mod", "B"], "non-mac")).toBe("Ctrl+B");
expect(formatShortcut(["mod", "E"], "non-mac")).toBe("Ctrl+E");
});
});
+53
View File
@@ -0,0 +1,53 @@
export type ShortcutKey =
| "mod"
| "shift"
| "alt"
| "ctrl"
| "meta"
| string;
export type ShortcutOs = "mac" | "non-mac";
function normalizeKey(key: string): string {
if (!key) return "";
if (key.length === 1) return key.toUpperCase();
return key;
}
export function formatShortcut(keys: ShortcutKey[], os: ShortcutOs): string {
const normalized = keys.map((k) => (typeof k === "string" ? k : String(k)));
if (os === "mac") {
const order = ["ctrl", "alt", "shift", "mod", "meta"];
const symbols: Record<string, string> = {
mod: "⌘",
shift: "⇧",
alt: "⌥",
ctrl: "⌃",
meta: "⌘",
};
const modifierSet = new Set(normalized);
const mods = order
.filter((k) => modifierSet.has(k))
.map((k) => symbols[k] ?? "");
const main = normalized
.filter((k) => !order.includes(k))
.map(normalizeKey)
.join("");
return `${mods.join("")}${main}`;
}
const labels: Record<string, string> = {
mod: "Ctrl",
shift: "Shift",
alt: "Alt",
ctrl: "Ctrl",
meta: "Win",
};
return normalized
.map((k) => labels[k] ?? normalizeKey(k))
.filter(Boolean)
.join("+");
}
@@ -0,0 +1,18 @@
import { Platform } from "react-native";
import { getIsTauriMac } from "@/constants/layout";
import type { ShortcutOs } from "@/utils/format-shortcut";
export function getShortcutOs(): ShortcutOs {
if (Platform.OS !== "web") {
return Platform.OS === "ios" ? "mac" : "non-mac";
}
if (getIsTauriMac()) return "mac";
if (typeof navigator === "undefined") return "non-mac";
const ua = navigator.userAgent ?? "";
const platform = (navigator as any).platform ?? "";
const isApple =
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) ||
/Mac|iPhone|iPad|iPod/i.test(platform);
return isApple ? "mac" : "non-mac";
}