feat(app): checkout diff pane styling and e2e test support
- Style diff file cards edge-to-edge with single divider between files - Remove monospace font from filenames for better readability - Add checkout-ship e2e test for app - Export Page type from e2e fixtures - Add checkout query hooks for diff, status, and PR status
This commit is contained in:
+119
-25
@@ -168,9 +168,10 @@ export default function HomeScreen() {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [promptText, setPromptText] = useState("");
|
||||
const [useWorktree, setUseWorktree] = useState(false);
|
||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
const [worktreeSlug, setWorktreeSlug] = useState("");
|
||||
const [selectedWorktreePath, setSelectedWorktreePath] = useState("");
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
|
||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
@@ -293,13 +294,73 @@ export default function HomeScreen() {
|
||||
const gitHelperText = isNonGitDirectory
|
||||
? "No git repository detected. Git options are disabled for this directory."
|
||||
: null;
|
||||
const isCreateWorktree = worktreeMode === "create";
|
||||
const isAttachWorktree = worktreeMode === "attach";
|
||||
|
||||
const handleUseWorktreeChange = useCallback((value: boolean) => {
|
||||
setUseWorktree(value);
|
||||
if (value && !worktreeSlug) {
|
||||
setWorktreeSlug(createNameId());
|
||||
}
|
||||
}, [worktreeSlug]);
|
||||
const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir;
|
||||
const worktreeListQuery = useQuery({
|
||||
queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot],
|
||||
queryFn: async () => {
|
||||
const client = sessionClient;
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getPaseoWorktreeList({
|
||||
repoRoot: worktreeListRoot || undefined,
|
||||
cwd: worktreeListRoot ? undefined : trimmedWorkingDir || undefined,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload.worktrees ?? [];
|
||||
},
|
||||
enabled:
|
||||
isAttachWorktree &&
|
||||
Boolean(worktreeListRoot || trimmedWorkingDir) &&
|
||||
!repoAvailabilityError &&
|
||||
Boolean(sessionClient) &&
|
||||
isConnected &&
|
||||
!isNonGitDirectory,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
const worktreeOptions = useMemo(() => {
|
||||
return (worktreeListQuery.data ?? []).map((worktree) => ({
|
||||
path: worktree.worktreePath,
|
||||
label: worktree.branchName ?? worktree.head ?? "Unknown branch",
|
||||
}));
|
||||
}, [worktreeListQuery.data]);
|
||||
const worktreeOptionsError =
|
||||
worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null;
|
||||
const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" =
|
||||
!isAttachWorktree
|
||||
? "idle"
|
||||
: worktreeListQuery.isPending || worktreeListQuery.isFetching
|
||||
? "loading"
|
||||
: worktreeListQuery.isError
|
||||
? "error"
|
||||
: "ready";
|
||||
const attachWorktreeError =
|
||||
isAttachWorktree &&
|
||||
worktreeOptionsStatus === "ready" &&
|
||||
worktreeOptions.length > 0 &&
|
||||
!selectedWorktreePath
|
||||
? "Select a worktree to attach"
|
||||
: null;
|
||||
|
||||
const handleWorktreeModeChange = useCallback(
|
||||
(mode: "none" | "create" | "attach") => {
|
||||
setWorktreeMode(mode);
|
||||
if (mode === "create" && !worktreeSlug) {
|
||||
setWorktreeSlug(createNameId());
|
||||
}
|
||||
if (mode !== "attach") {
|
||||
setSelectedWorktreePath("");
|
||||
}
|
||||
},
|
||||
[worktreeSlug]
|
||||
);
|
||||
|
||||
const validateWorktreeName = useCallback(
|
||||
(name: string): { valid: boolean; error?: string } => {
|
||||
@@ -330,7 +391,7 @@ export default function HomeScreen() {
|
||||
);
|
||||
|
||||
const gitBlockingError = useMemo(() => {
|
||||
if (!useWorktree || isNonGitDirectory) {
|
||||
if (!isCreateWorktree || isNonGitDirectory) {
|
||||
return null;
|
||||
}
|
||||
if (!worktreeSlug) {
|
||||
@@ -344,14 +405,14 @@ export default function HomeScreen() {
|
||||
}
|
||||
return null;
|
||||
}, [
|
||||
useWorktree,
|
||||
isCreateWorktree,
|
||||
isNonGitDirectory,
|
||||
worktreeSlug,
|
||||
validateWorktreeName,
|
||||
]);
|
||||
|
||||
const baseBranchError = useMemo(() => {
|
||||
if (!useWorktree || isNonGitDirectory || !baseBranch) {
|
||||
if (!isCreateWorktree || isNonGitDirectory || !baseBranch) {
|
||||
return null;
|
||||
}
|
||||
const branches = repoInfo?.branches ?? [];
|
||||
@@ -363,17 +424,26 @@ export default function HomeScreen() {
|
||||
return `Branch "${baseBranch}" not found in repository`;
|
||||
}
|
||||
return null;
|
||||
}, [useWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]);
|
||||
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]);
|
||||
|
||||
const handleBaseBranchChange = useCallback((value: string) => {
|
||||
setBaseBranch(value);
|
||||
}, []);
|
||||
|
||||
const handleSelectWorktreePath = useCallback(
|
||||
(path: string) => {
|
||||
setSelectedWorktreePath(path);
|
||||
setWorkingDirFromUser(path);
|
||||
},
|
||||
[setWorkingDirFromUser]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNonGitDirectory && useWorktree) {
|
||||
setUseWorktree(false);
|
||||
if (isNonGitDirectory && worktreeMode !== "none") {
|
||||
setWorktreeMode("none");
|
||||
setSelectedWorktreePath("");
|
||||
}
|
||||
}, [isNonGitDirectory, useWorktree]);
|
||||
}, [isNonGitDirectory, worktreeMode]);
|
||||
|
||||
const sessionMethods = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
|
||||
@@ -390,6 +460,10 @@ export default function HomeScreen() {
|
||||
setErrorMessage("");
|
||||
const trimmedPath = workingDir.trim();
|
||||
const trimmedPrompt = text.trim();
|
||||
const resolvedWorkingDir =
|
||||
isAttachWorktree && selectedWorktreePath
|
||||
? selectedWorktreePath
|
||||
: trimmedPath;
|
||||
if (!trimmedPath) {
|
||||
setErrorMessage("Working directory is required");
|
||||
throw new Error("Working directory is required");
|
||||
@@ -410,6 +484,11 @@ export default function HomeScreen() {
|
||||
setErrorMessage(gitBlockingError);
|
||||
throw new Error(gitBlockingError);
|
||||
}
|
||||
if (isAttachWorktree && !selectedWorktreePath) {
|
||||
const message = "Select a worktree to attach";
|
||||
setErrorMessage(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
if (baseBranchError) {
|
||||
setErrorMessage(baseBranchError);
|
||||
throw new Error(baseBranchError);
|
||||
@@ -427,18 +506,26 @@ export default function HomeScreen() {
|
||||
const trimmedModel = selectedModel.trim();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
cwd: trimmedPath,
|
||||
cwd: resolvedWorkingDir,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||
};
|
||||
const effectiveBaseBranch = baseBranch.trim() || repoInfo?.currentBranch || undefined;
|
||||
const gitOptions = useWorktree && !isNonGitDirectory && worktreeSlug
|
||||
? {
|
||||
createWorktree: true,
|
||||
worktreeSlug,
|
||||
baseBranch: effectiveBaseBranch,
|
||||
}
|
||||
: undefined;
|
||||
const effectiveWorktreeSlug =
|
||||
isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug;
|
||||
if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) {
|
||||
setWorktreeSlug(effectiveWorktreeSlug);
|
||||
}
|
||||
const gitOptions =
|
||||
isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug
|
||||
? {
|
||||
createWorktree: true,
|
||||
createNewBranch: true,
|
||||
newBranchName: effectiveWorktreeSlug,
|
||||
worktreeSlug: effectiveWorktreeSlug,
|
||||
baseBranch: effectiveBaseBranch,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
void persistFormPreferences();
|
||||
setIsLoading(true);
|
||||
@@ -469,9 +556,10 @@ export default function HomeScreen() {
|
||||
}
|
||||
},
|
||||
[
|
||||
useWorktree,
|
||||
worktreeMode,
|
||||
baseBranch,
|
||||
worktreeSlug,
|
||||
selectedWorktreePath,
|
||||
repoInfo?.currentBranch,
|
||||
gitBlockingError,
|
||||
baseBranchError,
|
||||
@@ -544,8 +632,8 @@ export default function HomeScreen() {
|
||||
/>
|
||||
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
|
||||
<GitOptionsSection
|
||||
useWorktree={useWorktree}
|
||||
onUseWorktreeChange={handleUseWorktreeChange}
|
||||
worktreeMode={worktreeMode}
|
||||
onWorktreeModeChange={handleWorktreeModeChange}
|
||||
worktreeSlug={worktreeSlug}
|
||||
currentBranch={repoInfo?.currentBranch ?? null}
|
||||
baseBranch={baseBranch}
|
||||
@@ -555,6 +643,12 @@ export default function HomeScreen() {
|
||||
repoError={repoInfoError}
|
||||
gitValidationError={gitBlockingError}
|
||||
baseBranchError={baseBranchError}
|
||||
worktreeOptions={worktreeOptions}
|
||||
selectedWorktreePath={selectedWorktreePath}
|
||||
worktreeOptionsStatus={worktreeOptionsStatus}
|
||||
worktreeOptionsError={worktreeOptionsError}
|
||||
attachWorktreeError={attachWorktreeError}
|
||||
onSelectWorktreePath={handleSelectWorktreePath}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -52,6 +52,7 @@ interface DropdownFieldProps {
|
||||
warningMessage?: string | null;
|
||||
helperText?: string | null;
|
||||
renderTrigger?: DropdownTriggerRenderer;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function DropdownField({
|
||||
@@ -64,6 +65,7 @@ export function DropdownField({
|
||||
warningMessage,
|
||||
helperText,
|
||||
renderTrigger,
|
||||
testID,
|
||||
}: DropdownFieldProps): ReactElement {
|
||||
if (renderTrigger) {
|
||||
return (
|
||||
@@ -88,6 +90,7 @@ export function DropdownField({
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
testID={testID}
|
||||
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
|
||||
>
|
||||
<Text
|
||||
@@ -117,6 +120,7 @@ interface SelectFieldProps {
|
||||
warningMessage?: string | null;
|
||||
helperText?: string | null;
|
||||
controlRef?: React.RefObject<View | null>;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
@@ -129,6 +133,7 @@ export function SelectField({
|
||||
warningMessage,
|
||||
helperText,
|
||||
controlRef,
|
||||
testID,
|
||||
}: SelectFieldProps): ReactElement {
|
||||
return (
|
||||
<View style={styles.selectFieldContainer}>
|
||||
@@ -136,6 +141,7 @@ export function SelectField({
|
||||
ref={controlRef}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
testID={testID}
|
||||
style={[styles.selectFieldControl, disabled && styles.selectFieldControlDisabled]}
|
||||
>
|
||||
<View style={styles.selectFieldContent}>
|
||||
@@ -1144,8 +1150,8 @@ export function ToggleRow({
|
||||
}
|
||||
|
||||
export interface GitOptionsSectionProps {
|
||||
useWorktree: boolean;
|
||||
onUseWorktreeChange: (value: boolean) => void;
|
||||
worktreeMode: "none" | "create" | "attach";
|
||||
onWorktreeModeChange: (value: "none" | "create" | "attach") => void;
|
||||
worktreeSlug: string;
|
||||
currentBranch: string | null;
|
||||
baseBranch: string;
|
||||
@@ -1155,11 +1161,17 @@ export interface GitOptionsSectionProps {
|
||||
repoError: string | null;
|
||||
gitValidationError: string | null;
|
||||
baseBranchError: string | null;
|
||||
worktreeOptions: Array<{ path: string; label: string }>;
|
||||
selectedWorktreePath: string;
|
||||
worktreeOptionsStatus: "idle" | "loading" | "ready" | "error";
|
||||
worktreeOptionsError: string | null;
|
||||
attachWorktreeError: string | null;
|
||||
onSelectWorktreePath: (path: string) => void;
|
||||
}
|
||||
|
||||
export function GitOptionsSection({
|
||||
useWorktree,
|
||||
onUseWorktreeChange,
|
||||
worktreeMode,
|
||||
onWorktreeModeChange,
|
||||
worktreeSlug,
|
||||
currentBranch,
|
||||
baseBranch,
|
||||
@@ -1169,11 +1181,20 @@ export function GitOptionsSection({
|
||||
repoError,
|
||||
gitValidationError,
|
||||
baseBranchError,
|
||||
worktreeOptions,
|
||||
selectedWorktreePath,
|
||||
worktreeOptionsStatus,
|
||||
worktreeOptionsError,
|
||||
attachWorktreeError,
|
||||
onSelectWorktreePath,
|
||||
}: GitOptionsSectionProps): ReactElement {
|
||||
const isLoading = status === "loading";
|
||||
const isCreateMode = worktreeMode === "create";
|
||||
const isAttachMode = worktreeMode === "attach";
|
||||
const [isEditingBranch, setIsEditingBranch] = useState(false);
|
||||
const [editedBranch, setEditedBranch] = useState(baseBranch);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [isWorktreeSheetOpen, setIsWorktreeSheetOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEditedBranch(baseBranch);
|
||||
@@ -1204,24 +1225,35 @@ export function GitOptionsSection({
|
||||
}, [baseBranch]);
|
||||
|
||||
const displayBranch = baseBranch || currentBranch || "HEAD";
|
||||
const selectedWorktreeLabel =
|
||||
worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? "";
|
||||
const worktreeHelperText =
|
||||
worktreeOptionsStatus === "loading"
|
||||
? "Loading worktrees..."
|
||||
: worktreeOptions.length === 0
|
||||
? "No worktrees found"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.gitOptionsContainer}>
|
||||
<Pressable
|
||||
onPress={() => onUseWorktreeChange(!useWorktree)}
|
||||
testID="worktree-create-toggle"
|
||||
onPress={() =>
|
||||
onWorktreeModeChange(isCreateMode ? "none" : "create")
|
||||
}
|
||||
disabled={isLoading}
|
||||
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
||||
>
|
||||
<View style={[styles.checkbox, useWorktree && styles.checkboxChecked]}>
|
||||
{useWorktree ? <View style={styles.checkboxDot} /> : null}
|
||||
<View style={[styles.checkbox, isCreateMode && styles.checkboxChecked]}>
|
||||
{isCreateMode ? <View style={styles.checkboxDot} /> : null}
|
||||
</View>
|
||||
<View style={styles.worktreeToggleContent}>
|
||||
<Text style={styles.worktreeToggleLabel}>Create worktree</Text>
|
||||
<Text style={styles.worktreeToggleDescription}>
|
||||
{isLoading
|
||||
? "Inspecting repository…"
|
||||
: useWorktree && worktreeSlug
|
||||
? `Will create: ${worktreeSlug}`
|
||||
: isCreateMode
|
||||
? `Will create: ${worktreeSlug || "preparing…"}`
|
||||
: currentBranch
|
||||
? `Run isolated from ${currentBranch}`
|
||||
: "Run in an isolated directory"}
|
||||
@@ -1229,7 +1261,59 @@ export function GitOptionsSection({
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{useWorktree ? (
|
||||
<Pressable
|
||||
testID="worktree-attach-toggle"
|
||||
onPress={() =>
|
||||
onWorktreeModeChange(isAttachMode ? "none" : "attach")
|
||||
}
|
||||
disabled={isLoading}
|
||||
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
||||
>
|
||||
<View style={[styles.checkbox, isAttachMode && styles.checkboxChecked]}>
|
||||
{isAttachMode ? <View style={styles.checkboxDot} /> : null}
|
||||
</View>
|
||||
<View style={styles.worktreeToggleContent}>
|
||||
<Text style={styles.worktreeToggleLabel}>Attach to existing worktree</Text>
|
||||
<Text style={styles.worktreeToggleDescription}>
|
||||
{isLoading ? "Inspecting repository…" : "Pick a Paseo worktree by branch"}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{isAttachMode ? (
|
||||
<>
|
||||
<SelectField
|
||||
label="Worktree"
|
||||
value={selectedWorktreeLabel}
|
||||
placeholder="Select a worktree"
|
||||
onPress={() => setIsWorktreeSheetOpen(true)}
|
||||
disabled={isLoading || worktreeOptionsStatus === "loading"}
|
||||
helperText={worktreeHelperText}
|
||||
errorMessage={attachWorktreeError || worktreeOptionsError}
|
||||
testID="worktree-attach-picker"
|
||||
/>
|
||||
<DropdownSheet
|
||||
title="Select worktree"
|
||||
visible={isWorktreeSheetOpen}
|
||||
onClose={() => setIsWorktreeSheetOpen(false)}
|
||||
>
|
||||
{worktreeOptions.map((option) => (
|
||||
<Pressable
|
||||
key={option.path}
|
||||
style={styles.dropdownSheetOption}
|
||||
onPress={() => {
|
||||
onSelectWorktreePath(option.path);
|
||||
setIsWorktreeSheetOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text style={styles.dropdownSheetOptionLabel}>{option.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</DropdownSheet>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isCreateMode ? (
|
||||
<View style={styles.baseBranchRow}>
|
||||
<Text style={styles.baseBranchLabel}>Base branch:</Text>
|
||||
{isEditingBranch ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo } from "react";
|
||||
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -11,14 +11,17 @@ import {
|
||||
} from "react-native";
|
||||
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { ChevronRight, GitBranch } from "lucide-react-native";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
useHighlightedDiffQuery,
|
||||
useCheckoutDiffQuery,
|
||||
type ParsedDiffFile,
|
||||
type DiffLine,
|
||||
type HighlightToken,
|
||||
} from "@/hooks/use-highlighted-diff-query";
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
@@ -260,6 +263,7 @@ const DiffFileSection = memo(function DiffFileSection({
|
||||
return (
|
||||
<View style={styles.fileSection} testID={testID}>
|
||||
<Pressable
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={({ pressed }) => [
|
||||
styles.fileHeader,
|
||||
pressed && styles.fileHeaderPressed,
|
||||
@@ -341,9 +345,44 @@ interface GitDiffPaneProps {
|
||||
|
||||
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { files, isLoading, isFetching, isError, error, refresh } = useHighlightedDiffQuery({
|
||||
const queryClient = useQueryClient();
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const [diffMode, setDiffMode] = useState<"uncommitted" | "base">("uncommitted");
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||
useCheckoutStatusQuery({ serverId, agentId });
|
||||
const isGit = status?.isGit ?? false;
|
||||
const notGit = Boolean(status) && !status?.isGit && !status?.error;
|
||||
const statusErrorMessage =
|
||||
status?.error?.message ??
|
||||
(isStatusError && statusError instanceof Error ? statusError.message : null);
|
||||
const baseRef = status?.baseRef ?? undefined;
|
||||
const {
|
||||
files,
|
||||
payloadError: diffPayloadError,
|
||||
isLoading: isDiffLoading,
|
||||
isFetching: isDiffFetching,
|
||||
isError: isDiffError,
|
||||
error: diffError,
|
||||
refresh: refreshDiff,
|
||||
} = useCheckoutDiffQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
mode: diffMode,
|
||||
baseRef,
|
||||
enabled: isGit,
|
||||
});
|
||||
const {
|
||||
status: prStatus,
|
||||
payloadError: prPayloadError,
|
||||
refresh: refreshPrStatus,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled: isGit,
|
||||
});
|
||||
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
@@ -374,8 +413,10 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
void refreshDiff();
|
||||
void refreshStatus();
|
||||
void refreshPrStatus();
|
||||
}, [refreshDiff, refreshStatus, refreshPrStatus]);
|
||||
|
||||
const handleToggleExpanded = useCallback((path: string) => {
|
||||
setExpandedByPath((prev) => ({
|
||||
@@ -386,10 +427,10 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
|
||||
// Reset manual refresh flag when fetch completes
|
||||
useEffect(() => {
|
||||
if (!isFetching && isManualRefresh) {
|
||||
if (!(isDiffFetching || isStatusFetching) && isManualRefresh) {
|
||||
setIsManualRefresh(false);
|
||||
}
|
||||
}, [isFetching, isManualRefresh]);
|
||||
}, [isDiffFetching, isStatusFetching, isManualRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPerfLoggingEnabled()) {
|
||||
@@ -408,15 +449,120 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
hunkCount: diffMetrics.hunkCount,
|
||||
lineCount: diffMetrics.lineCount,
|
||||
tokenCount: diffMetrics.tokenCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isLoading: isDiffLoading,
|
||||
isFetching: isDiffFetching,
|
||||
});
|
||||
}, [agentId, diffMetrics, isFetching, isLoading, serverId]);
|
||||
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
|
||||
|
||||
const agentExists = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agents?.has(agentId) ?? false
|
||||
);
|
||||
|
||||
const commitMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.checkoutCommit(agentId, { addAll: true });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
setActionStatus("Commit created.");
|
||||
void refreshDiff();
|
||||
void refreshStatus();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to commit";
|
||||
setActionStatus(null);
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const prMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.checkoutPrCreate(agentId, {});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
setActionStatus("PR created.");
|
||||
void refreshPrStatus();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to create PR";
|
||||
setActionStatus(null);
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const mergeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.checkoutMerge(agentId, {
|
||||
baseRef,
|
||||
strategy: "merge",
|
||||
requireCleanTarget: true,
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
setActionStatus("Merged to base.");
|
||||
void refreshDiff();
|
||||
void refreshStatus();
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to merge";
|
||||
setActionStatus(null);
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
setActionStatus("Worktree archived.");
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||
setActionStatus(null);
|
||||
setActionError(message);
|
||||
},
|
||||
});
|
||||
|
||||
const renderFileSection: ListRenderItem<ParsedDiffFile> = useCallback(
|
||||
({ item, index }) => (
|
||||
<DiffFileSection
|
||||
@@ -440,58 +586,321 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
}
|
||||
|
||||
const hasChanges = files.length > 0;
|
||||
const errorMessage = isError && error instanceof Error ? error.message : null;
|
||||
const diffErrorMessage =
|
||||
diffPayloadError?.message ??
|
||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
||||
const prErrorMessage = prPayloadError?.message ?? null;
|
||||
const branchLabel = status?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown");
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const hasUncommittedChanges = Boolean(status?.isDirty);
|
||||
const showCommitAction =
|
||||
!isGit || hasUncommittedChanges || (diffMode === "uncommitted" && hasChanges);
|
||||
const showCreatePrAction = !isGit || !prStatus;
|
||||
const commitDisabled = actionsDisabled || commitMutation.isPending;
|
||||
const prDisabled = actionsDisabled || prMutation.isPending;
|
||||
const mergeDisabled = actionsDisabled || mergeMutation.isPending;
|
||||
const archiveDisabled =
|
||||
actionsDisabled || archiveMutation.isPending || !status?.isPaseoOwnedWorktree;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
let bodyContent: ReactElement;
|
||||
|
||||
if (isStatusLoading) {
|
||||
bodyContent = (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Checking repository...</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (statusErrorMessage) {
|
||||
bodyContent = (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{statusErrorMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (notGit) {
|
||||
bodyContent = (
|
||||
<View style={styles.emptyContainer} testID="changes-not-git">
|
||||
<Text style={styles.emptyText}>Not a git repository</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (isDiffLoading) {
|
||||
bodyContent = (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" />
|
||||
<Text style={styles.loadingText}>Loading changes...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
} else if (diffErrorMessage) {
|
||||
bodyContent = (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{errorMessage ?? "Failed to load changes"}</Text>
|
||||
<Text style={styles.errorText}>{diffErrorMessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
return (
|
||||
} else if (!hasChanges) {
|
||||
bodyContent = (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>No changes</Text>
|
||||
<Text style={styles.emptyText}>
|
||||
{diffMode === "uncommitted" ? "No uncommitted changes" : "No base changes"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
bodyContent = (
|
||||
<FlatList
|
||||
data={files}
|
||||
renderItem={renderFileSection}
|
||||
keyExtractor={keyExtractor}
|
||||
extraData={expandedByPath}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="git-diff-scroll"
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={isManualRefresh && isDiffFetching}
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={files}
|
||||
renderItem={renderFileSection}
|
||||
keyExtractor={keyExtractor}
|
||||
extraData={expandedByPath}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="git-diff-scroll"
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={isManualRefresh && isFetching}
|
||||
initialNumToRender={3}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={5}
|
||||
/>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header} testID="changes-header">
|
||||
<View style={styles.headerLeft}>
|
||||
<GitBranch size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
|
||||
{branchLabel}
|
||||
</Text>
|
||||
{isStatusFetching && (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.modeToggle}>
|
||||
<Pressable
|
||||
testID="changes-mode-uncommitted"
|
||||
onPress={() => setDiffMode("uncommitted")}
|
||||
style={[
|
||||
styles.modeToggleButton,
|
||||
diffMode === "uncommitted" && styles.modeToggleButtonActive,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.modeToggleText,
|
||||
diffMode === "uncommitted" && styles.modeToggleTextActive,
|
||||
]}
|
||||
>
|
||||
Uncommitted
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="changes-mode-base"
|
||||
onPress={() => setDiffMode("base")}
|
||||
style={[
|
||||
styles.modeToggleButton,
|
||||
diffMode === "base" && styles.modeToggleButtonActive,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.modeToggleText,
|
||||
diffMode === "base" && styles.modeToggleTextActive,
|
||||
]}
|
||||
>
|
||||
Base
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionsRow}>
|
||||
{showCommitAction ? (
|
||||
<Pressable
|
||||
testID="changes-action-commit"
|
||||
style={[styles.actionButton, commitDisabled && styles.actionButtonDisabled]}
|
||||
onPress={() => commitMutation.mutate()}
|
||||
disabled={commitDisabled}
|
||||
>
|
||||
{commitMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Text style={styles.actionButtonText}>Commit</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
{showCreatePrAction ? (
|
||||
<Pressable
|
||||
testID="changes-action-create-pr"
|
||||
style={[styles.actionButton, prDisabled && styles.actionButtonDisabled]}
|
||||
onPress={() => prMutation.mutate()}
|
||||
disabled={prDisabled}
|
||||
>
|
||||
{prMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Text style={styles.actionButtonText}>Create PR</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
testID="changes-action-merge"
|
||||
style={[styles.actionButton, mergeDisabled && styles.actionButtonDisabled]}
|
||||
onPress={() => mergeMutation.mutate()}
|
||||
disabled={mergeDisabled}
|
||||
>
|
||||
{mergeMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Text style={styles.actionButtonText}>Merge to base</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="changes-action-archive"
|
||||
style={[styles.actionButton, archiveDisabled && styles.actionButtonDisabled]}
|
||||
onPress={() => archiveMutation.mutate()}
|
||||
disabled={archiveDisabled}
|
||||
>
|
||||
{archiveMutation.isPending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Text style={styles.actionButtonText}>Archive</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{actionStatus ? <Text style={styles.actionStatusText}>{actionStatus}</Text> : null}
|
||||
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
|
||||
{prStatus ? (
|
||||
<View style={styles.prStatusRow} testID="changes-pr-status">
|
||||
<Text style={styles.prStatusLabel}>PR</Text>
|
||||
<Text style={styles.prStatusValue}>
|
||||
{prStatus.state} {prStatus.url ? `· ${prStatus.url}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{prErrorMessage ? (
|
||||
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
|
||||
) : null}
|
||||
|
||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
branchLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
modeToggle: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[1],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
modeToggleButton: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
modeToggleButtonActive: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
modeToggleText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
modeToggleTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
actionButton: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
actionButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
actionStatusText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[1],
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.success,
|
||||
},
|
||||
actionErrorText: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[1],
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
prStatusRow: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
prStatusLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
prStatusValue: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
diffContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[8],
|
||||
},
|
||||
loadingContainer: {
|
||||
@@ -528,20 +937,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
fileSection: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
marginBottom: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.borderAccent,
|
||||
},
|
||||
fileHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
fileHeaderPressed: {
|
||||
opacity: 0.7,
|
||||
@@ -549,14 +956,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fileHeaderLeft: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
fileHeaderRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 0,
|
||||
},
|
||||
chevronContainer: {
|
||||
@@ -569,7 +976,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
flex: 1,
|
||||
},
|
||||
newBadge: {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import type { CheckoutDiffResponse } from "@server/shared/messages";
|
||||
|
||||
const CHECKOUT_DIFF_STALE_TIME = 30_000;
|
||||
|
||||
function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string
|
||||
) {
|
||||
return ["checkoutDiff", serverId, agentId, mode, baseRef ?? ""] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type ParsedDiffFile = CheckoutDiffResponse["payload"]["files"][number];
|
||||
export type DiffHunk = ParsedDiffFile["hunks"][number];
|
||||
export type DiffLine = DiffHunk["lines"][number];
|
||||
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
mode,
|
||||
baseRef,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutDiffQueryKey(serverId, agentId, mode, baseRef),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.getCheckoutDiff(agentId, { mode, baseRef });
|
||||
},
|
||||
enabled: !!client && isConnected && !!agentId && enabled,
|
||||
staleTime: CHECKOUT_DIFF_STALE_TIME,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
// Revalidate when sidebar opens with "changes" tab active
|
||||
useEffect(() => {
|
||||
if (!isOpen || explorerTab !== "changes" || !agentId) {
|
||||
return;
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: checkoutDiffQueryKey(serverId, agentId, mode, baseRef),
|
||||
});
|
||||
}, [isOpen, explorerTab, serverId, agentId, mode, baseRef, queryClient]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
const payload = query.data ?? null;
|
||||
const payloadError = payload?.error ?? null;
|
||||
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError || Boolean(payloadError),
|
||||
error: query.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
||||
|
||||
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
|
||||
|
||||
function checkoutPrStatusQueryKey(serverId: string, agentId: string) {
|
||||
return ["checkoutPrStatus", serverId, agentId] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutPrStatusQueryOptions {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
|
||||
export function useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
agentId,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions) {
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.checkoutPrStatus(agentId);
|
||||
},
|
||||
enabled: !!client && isConnected && !!agentId && enabled,
|
||||
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
return {
|
||||
status: query.data?.status ?? null,
|
||||
payloadError: query.data?.error ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
|
||||
const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||
|
||||
function checkoutStatusQueryKey(serverId: string, agentId: string) {
|
||||
return ["checkoutStatus", serverId, agentId] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutStatusQueryOptions {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
|
||||
export function useCheckoutStatusQuery({ serverId, agentId }: UseCheckoutStatusQueryOptions) {
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.getCheckoutStatus(agentId);
|
||||
},
|
||||
enabled: !!client && isConnected && !!agentId,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
return {
|
||||
status: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,12 @@ import type { Agent } from "@/stores/session-store";
|
||||
export function extractAgentModel(agent?: Agent | null): string | null {
|
||||
if (!agent) return null;
|
||||
const runtimeModel = agent.runtimeInfo?.model;
|
||||
return typeof runtimeModel === "string" && runtimeModel.trim().length > 0
|
||||
? runtimeModel.trim()
|
||||
: null;
|
||||
const fallbackModel = agent.model;
|
||||
if (typeof runtimeModel === "string" && runtimeModel.trim().length > 0) {
|
||||
return runtimeModel.trim();
|
||||
}
|
||||
if (typeof fallbackModel === "string" && fallbackModel.trim().length > 0) {
|
||||
return fallbackModel.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user