consolidate checkout status handling and improve git detection
This commit is contained in:
@@ -791,7 +791,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
|
||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
||||
const prErrorMessage = prPayloadError?.message ?? null;
|
||||
const branchLabel =
|
||||
gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown");
|
||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||
? gitStatus.currentBranch
|
||||
: notGit
|
||||
? "Not a git repository"
|
||||
: "Unknown";
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
||||
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
||||
|
||||
@@ -440,7 +440,11 @@ export function GroupedAgentList({
|
||||
});
|
||||
const checkout = checkoutQuery.data ?? null;
|
||||
const activeBranchLabel = checkout?.isGit
|
||||
? (checkout.currentBranch ?? checkout.baseRef ?? "git")
|
||||
? ((checkout.currentBranch && checkout.currentBranch !== "HEAD"
|
||||
? checkout.currentBranch
|
||||
: null) ??
|
||||
checkout.baseRef ??
|
||||
"git")
|
||||
: null;
|
||||
|
||||
const canArchive = !isRunning && !agent.requiresAttention;
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("useCheckoutStatusQuery", () => {
|
||||
|
||||
describe("nextCheckoutStatusRefetchDecision", () => {
|
||||
it("refetches only once per key until reset", () => {
|
||||
const key = "daemon-1:agent-1";
|
||||
const key = "daemon-1:/path/to/project";
|
||||
|
||||
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
|
||||
nextSeenKey: key,
|
||||
@@ -62,11 +62,11 @@ describe("useCheckoutStatusQuery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refetches again when agent changes while active", () => {
|
||||
it("refetches again when cwd changes while active", () => {
|
||||
expect(
|
||||
nextCheckoutStatusRefetchDecision("daemon-1:agent-1", "daemon-1:agent-2")
|
||||
nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")
|
||||
).toEqual({
|
||||
nextSeenKey: "daemon-1:agent-2",
|
||||
nextSeenKey: "daemon-1:/path/b",
|
||||
shouldRefetch: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ReanimatedAnimated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -72,9 +71,6 @@ import {
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
|
||||
type BranchStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
export function AgentReadyScreen({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -372,43 +368,6 @@ function AgentScreenContent({
|
||||
const agentModel = extractAgentModel(agent);
|
||||
const modelDisplayValue = agentModel ?? "Unknown";
|
||||
|
||||
const repoInfoQuery = useQuery({
|
||||
queryKey: ["checkoutStatus", serverId, agent?.cwd ?? ""],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getCheckoutStatus(agent?.cwd ?? ".");
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return {
|
||||
cwd: payload.cwd,
|
||||
currentBranch: payload.currentBranch ?? null,
|
||||
};
|
||||
},
|
||||
enabled: Boolean(client && isConnected && agent?.cwd),
|
||||
retry: false,
|
||||
});
|
||||
const { refetch: refetchRepoInfo } = repoInfoQuery;
|
||||
const branchStatus: BranchStatus = !agent?.cwd
|
||||
? "idle"
|
||||
: repoInfoQuery.isPending || repoInfoQuery.isFetching
|
||||
? "loading"
|
||||
: repoInfoQuery.isError
|
||||
? "error"
|
||||
: repoInfoQuery.isSuccess
|
||||
? "ready"
|
||||
: "idle";
|
||||
const branchLabel = repoInfoQuery.data?.currentBranch ?? null;
|
||||
const branchError = repoInfoQuery.error instanceof Error
|
||||
? repoInfoQuery.error.message
|
||||
: null;
|
||||
const branchDisplayValue =
|
||||
branchStatus === "error"
|
||||
? branchError ?? "Unavailable"
|
||||
: branchLabel ?? "Unknown";
|
||||
|
||||
// Checkout status for header subtitle
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({
|
||||
serverId,
|
||||
@@ -648,7 +607,7 @@ function AgentScreenContent({
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open && agent?.cwd) {
|
||||
refetchRepoInfo().catch(() => {});
|
||||
checkoutStatusQuery.refresh().catch(() => {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -679,31 +638,30 @@ function AgentScreenContent({
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.menuMetaRow}>
|
||||
<Text style={styles.menuMetaLabel}>Branch</Text>
|
||||
<View style={styles.menuMetaValueRow}>
|
||||
{branchStatus === "loading" ? (
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={styles.menuMetaPendingText}>Fetching…</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.menuMetaValue,
|
||||
branchStatus === "error" ? styles.menuMetaValueError : null,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{branchDisplayValue}
|
||||
</Text>
|
||||
)}
|
||||
{checkout?.isGit && checkout.currentBranch && checkout.currentBranch !== "HEAD" ? (
|
||||
<View style={styles.menuMetaRow}>
|
||||
<Text style={styles.menuMetaLabel}>Branch</Text>
|
||||
<View style={styles.menuMetaValueRow}>
|
||||
{checkoutStatusQuery.isFetching ? (
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={styles.menuMetaPendingText}>Fetching…</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
style={styles.menuMetaValue}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
>
|
||||
{checkout.currentBranch}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
checkoutStatusQueryKey,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
@@ -309,82 +313,62 @@ export function DraftAgentScreen({
|
||||
"Repository details will load automatically once the selected host is back online."
|
||||
: null;
|
||||
|
||||
type RepoInfoState = {
|
||||
cwd: string;
|
||||
repoRoot: string;
|
||||
currentBranch: string | null;
|
||||
isDirty: boolean;
|
||||
};
|
||||
const repoInfoQuery = useQuery({
|
||||
queryKey: ["checkoutStatus", selectedServerId, trimmedWorkingDir],
|
||||
const checkoutStatusQuery = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(selectedServerId ?? "", trimmedWorkingDir),
|
||||
queryFn: async () => {
|
||||
const client = sessionClient;
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getCheckoutStatus(trimmedWorkingDir || ".");
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
if (!payload.isGit) {
|
||||
throw new Error("Not a git repository");
|
||||
}
|
||||
// After the isGit check, TypeScript knows we have a git repo
|
||||
return {
|
||||
cwd: payload.cwd,
|
||||
repoRoot: payload.repoRoot,
|
||||
currentBranch: payload.currentBranch,
|
||||
isDirty: payload.isDirty,
|
||||
};
|
||||
return await client.getCheckoutStatus(trimmedWorkingDir);
|
||||
},
|
||||
enabled:
|
||||
Boolean(selectedServerId) &&
|
||||
Boolean(trimmedWorkingDir) &&
|
||||
!repoAvailabilityError &&
|
||||
Boolean(sessionClient) &&
|
||||
isConnected,
|
||||
retry: false,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
const repoInfo = repoInfoQuery.data ?? null;
|
||||
const refetchRepoInfo = repoInfoQuery.refetch;
|
||||
const repoRequestError = repoInfoQuery.error as Error | null;
|
||||
const repoRequestStatus: "idle" | "loading" | "success" | "error" =
|
||||
!shouldInspectRepo || repoAvailabilityError
|
||||
? "idle"
|
||||
: repoInfoQuery.isPending || repoInfoQuery.isFetching
|
||||
? "loading"
|
||||
: repoInfoQuery.isError
|
||||
? "error"
|
||||
: repoInfoQuery.isSuccess
|
||||
? "success"
|
||||
: "idle";
|
||||
|
||||
const checkout = checkoutStatusQuery.data ?? null;
|
||||
const refetchCheckoutStatus = checkoutStatusQuery.refetch;
|
||||
const checkoutQueryError =
|
||||
checkoutStatusQuery.error instanceof Error ? checkoutStatusQuery.error.message : null;
|
||||
const checkoutPayloadError = checkout?.error ? checkout.error.message : null;
|
||||
|
||||
const isNonGitDirectory =
|
||||
repoRequestStatus === "error" &&
|
||||
/not in a git repository|not a git repository/i.test(repoRequestError?.message ?? "");
|
||||
Boolean(trimmedWorkingDir) &&
|
||||
checkoutStatusQuery.isSuccess &&
|
||||
checkout?.isGit === false &&
|
||||
checkout?.error == null;
|
||||
|
||||
const isDirectoryNotExists =
|
||||
repoRequestStatus === "error" &&
|
||||
/does not exist|no such file or directory|ENOENT/i.test(repoRequestError?.message ?? "");
|
||||
checkoutStatusQuery.isError &&
|
||||
/does not exist|no such file or directory|ENOENT/i.test(checkoutQueryError ?? "");
|
||||
|
||||
const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo
|
||||
? "idle"
|
||||
: repoAvailabilityError
|
||||
? "error"
|
||||
: repoRequestStatus === "loading"
|
||||
: checkoutStatusQuery.isPending || checkoutStatusQuery.isFetching
|
||||
? "loading"
|
||||
: repoRequestStatus === "error"
|
||||
? isNonGitDirectory
|
||||
? "idle"
|
||||
: "error"
|
||||
: repoRequestStatus === "success"
|
||||
: checkoutStatusQuery.isError || Boolean(checkoutPayloadError)
|
||||
? "error"
|
||||
: checkout?.isGit
|
||||
? "ready"
|
||||
: "idle";
|
||||
|
||||
const repoInfoError =
|
||||
repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null);
|
||||
const gitHelperText = isNonGitDirectory
|
||||
? "No git repository detected. Git options are disabled for this directory."
|
||||
: null;
|
||||
repoAvailabilityError ??
|
||||
(checkoutStatusQuery.isError ? checkoutQueryError : null) ??
|
||||
checkoutPayloadError;
|
||||
const isCreateWorktree = worktreeMode === "create";
|
||||
const isAttachWorktree = worktreeMode === "attach";
|
||||
|
||||
const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir;
|
||||
const worktreeListRoot = checkout?.repoRoot ?? trimmedWorkingDir;
|
||||
const worktreeListQuery = useQuery({
|
||||
queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot],
|
||||
queryFn: async () => {
|
||||
@@ -446,10 +430,10 @@ export function DraftAgentScreen({
|
||||
setSelectedWorktreePath("");
|
||||
}
|
||||
if (mode !== "none") {
|
||||
refetchRepoInfo();
|
||||
refetchCheckoutStatus();
|
||||
}
|
||||
},
|
||||
[worktreeSlug, refetchRepoInfo]
|
||||
[worktreeSlug, refetchCheckoutStatus]
|
||||
);
|
||||
|
||||
const validateWorktreeName = useCallback(
|
||||
@@ -567,11 +551,14 @@ export function DraftAgentScreen({
|
||||
if (baseBranch) {
|
||||
return;
|
||||
}
|
||||
const current = repoInfo?.currentBranch?.trim();
|
||||
const current = checkout?.isGit ? checkout.currentBranch?.trim() : null;
|
||||
if (!current || current === "HEAD") {
|
||||
return;
|
||||
}
|
||||
if (current) {
|
||||
setBaseBranch(current);
|
||||
}
|
||||
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.currentBranch]);
|
||||
}, [isCreateWorktree, isNonGitDirectory, baseBranch, checkout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNonGitDirectory && worktreeMode !== "none") {
|
||||
@@ -787,7 +774,6 @@ export function DraftAgentScreen({
|
||||
baseBranch,
|
||||
worktreeSlug,
|
||||
selectedWorktreePath,
|
||||
repoInfo?.currentBranch,
|
||||
gitBlockingError,
|
||||
baseBranchError,
|
||||
isDirectoryNotExists,
|
||||
@@ -891,7 +877,7 @@ export function DraftAgentScreen({
|
||||
worktreeMode={worktreeMode}
|
||||
onWorktreeModeChange={handleWorktreeModeChange}
|
||||
worktreeSlug={worktreeSlug}
|
||||
currentBranch={repoInfo?.currentBranch ?? null}
|
||||
currentBranch={checkout?.isGit ? checkout.currentBranch ?? null : null}
|
||||
baseBranch={baseBranch}
|
||||
onBaseBranchChange={handleBaseBranchChange}
|
||||
status={repoInfoStatus}
|
||||
|
||||
@@ -15,6 +15,9 @@ export function deriveBranchLabel(
|
||||
if (!currentBranch) {
|
||||
return null;
|
||||
}
|
||||
if (currentBranch === "HEAD") {
|
||||
return null;
|
||||
}
|
||||
if (baseRef && currentBranch === baseRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -219,6 +219,65 @@ describe("parseToolCallDisplay - apply_patch (Codex)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("parses apply_patch with kind object (type/move_path) into edit type", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/moboudra/-paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts",
|
||||
kind: { type: "update", move_path: null },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = {
|
||||
files: [
|
||||
{
|
||||
path: "/Users/moboudra/-paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts",
|
||||
patch: "@@ -1,1 +1,1 @@\n-foo\n+bar",
|
||||
kind: { type: "update", move_path: null },
|
||||
},
|
||||
],
|
||||
success: true,
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("apply_patch", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
expect(display.toolName).toBe("Edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe(
|
||||
"/Users/moboudra/-paseo/worktrees/paseo/naive-zebra/packages/server/src/server/daemon-keypair.ts"
|
||||
);
|
||||
expect(display.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-foo\n+bar");
|
||||
}
|
||||
});
|
||||
|
||||
test("prefers move_path for display but still finds patch by original path", () => {
|
||||
const input = {
|
||||
files: [
|
||||
{
|
||||
path: "/some/old-path.txt",
|
||||
kind: { type: "update", move_path: "/some/new-path.txt" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = {
|
||||
files: [
|
||||
{
|
||||
path: "/some/old-path.txt",
|
||||
patch: "@@ -1,1 +1,1 @@\n-old\n+new",
|
||||
kind: { type: "update", move_path: "/some/new-path.txt" },
|
||||
},
|
||||
],
|
||||
success: true,
|
||||
};
|
||||
|
||||
const display = parseToolCallDisplay("apply_patch", input, result);
|
||||
expect(display.type).toBe("edit");
|
||||
if (display.type === "edit") {
|
||||
expect(display.filePath).toBe("/some/new-path.txt");
|
||||
expect(display.unifiedDiff).toBe("@@ -1,1 +1,1 @@\n-old\n+new");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses pending apply_patch (no result yet)", () => {
|
||||
const input = {
|
||||
files: [
|
||||
|
||||
@@ -1041,20 +1041,42 @@ const EditToolCallSchema = z
|
||||
};
|
||||
});
|
||||
|
||||
// Codex apply_patch input: { files: [{ path: string, kind: string }] }
|
||||
const ApplyPatchFileKindSchema = z
|
||||
.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
move_path: z.string().nullable().optional(),
|
||||
movePath: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
.optional();
|
||||
|
||||
function getApplyPatchMovePath(kind: unknown): string | undefined {
|
||||
if (!kind || typeof kind !== "object") return undefined;
|
||||
const record = kind as Record<string, unknown>;
|
||||
const movePath = typeof record.movePath === "string" ? record.movePath : undefined;
|
||||
const movePathSnake =
|
||||
typeof record.move_path === "string" ? record.move_path : undefined;
|
||||
return movePath ?? movePathSnake ?? undefined;
|
||||
}
|
||||
|
||||
// Codex apply_patch input: { files: [{ path: string, kind: string | object }] }
|
||||
const ApplyPatchInputSchema = z.object({
|
||||
files: z.array(z.object({
|
||||
path: z.string(),
|
||||
kind: z.string().optional(),
|
||||
kind: ApplyPatchFileKindSchema,
|
||||
})).min(1),
|
||||
}).passthrough();
|
||||
|
||||
// Codex apply_patch result: { files: [{ path: string, patch: string, kind: string }], message: string, success: boolean }
|
||||
// Codex apply_patch result: { files: [{ path: string, patch: string, kind: string | object }], message: string, success: boolean }
|
||||
const ApplyPatchResultSchema = z.object({
|
||||
files: z.array(z.object({
|
||||
path: z.string(),
|
||||
patch: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
kind: ApplyPatchFileKindSchema,
|
||||
})).optional(),
|
||||
message: z.string().optional(),
|
||||
success: z.boolean().optional(),
|
||||
@@ -1068,13 +1090,19 @@ const ApplyPatchToolCallSchema = z
|
||||
})
|
||||
.transform((data): { type: "edit"; filePath: string; oldString: string; newString: string; unifiedDiff?: string } => {
|
||||
const firstFile = data.input.files[0];
|
||||
const filePath = firstFile.path;
|
||||
const movePath = getApplyPatchMovePath(firstFile.kind);
|
||||
const filePath = movePath ?? firstFile.path;
|
||||
|
||||
// Try to get the patch from the result
|
||||
const resultParsed = ApplyPatchResultSchema.safeParse(data.result);
|
||||
let unifiedDiff: string | undefined;
|
||||
if (resultParsed.success && resultParsed.data.files) {
|
||||
const resultFile = resultParsed.data.files.find(f => f.path === filePath) ?? resultParsed.data.files[0];
|
||||
const matchPaths = new Set<string>([firstFile.path]);
|
||||
if (movePath) matchPaths.add(movePath);
|
||||
|
||||
const resultFile =
|
||||
resultParsed.data.files.find((f) => matchPaths.has(f.path)) ??
|
||||
resultParsed.data.files[0];
|
||||
unifiedDiff = resultFile?.patch;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user