Merge branch 'main' into dev

This commit is contained in:
Mohamed Boudra
2026-04-10 14:44:57 +07:00
parent 4176bd8aa1
commit 0deaed3794
22 changed files with 923 additions and 160 deletions
@@ -93,6 +93,7 @@ export type UseAgentFormStateResult = {
isModelLoading: boolean;
modelError: string | null;
refreshProviderModels: () => void;
invalidateProviderModels: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>;
@@ -373,6 +374,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isFetching: snapshotIsFetching,
error: snapshotError,
refresh: refreshSnapshot,
invalidate: invalidateSnapshot,
} = useProvidersSnapshot(formState.serverId);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
@@ -646,6 +648,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
refreshSnapshot();
}, [refreshSnapshot]);
const invalidateProviderModels = useCallback(() => {
invalidateSnapshot();
}, [invalidateSnapshot]);
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
const modelId = resolvedModel?.id ?? formState.model;
@@ -712,6 +718,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isModelLoading,
modelError,
refreshProviderModels,
invalidateProviderModels,
setProviderAndModelFromUser,
workingDirIsEmpty,
persistFormPreferences,
@@ -743,6 +750,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
isModelLoading,
modelError,
refreshProviderModels,
invalidateProviderModels,
setProviderAndModelFromUser,
workingDirIsEmpty,
persistFormPreferences,
@@ -159,6 +159,7 @@ function buildDraftStatusControls(input: {
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.invalidateProviderModels,
};
}
@@ -0,0 +1,164 @@
import { useState, useCallback, useMemo } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host";
import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
import { confirmDialog } from "@/utils/confirm-dialog";
interface UseBranchSwitcherInput {
client: DaemonClient | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
currentBranchName: string | null;
isGitCheckout: boolean;
isConnected: boolean;
toast: ToastApi;
queryClient: QueryClient;
}
interface UseBranchSwitcherResult {
branchOptions: ComboboxOption[];
isOpen: boolean;
setIsOpen: (open: boolean) => void;
handleBranchSelect: (branchId: string) => void;
invalidateStashAndCheckout: () => Promise<void>;
}
export function useBranchSwitcher({
client,
normalizedServerId,
normalizedWorkspaceId,
currentBranchName,
isGitCheckout,
isConnected,
toast,
queryClient,
}: UseBranchSwitcherInput): UseBranchSwitcherResult {
const [isOpen, setIsOpen] = useState(false);
const branchSuggestionsQuery = useQuery({
queryKey: ["branchSuggestions", normalizedServerId, normalizedWorkspaceId],
queryFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getBranchSuggestions({
cwd: normalizedWorkspaceId,
limit: 200,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.branches ?? [];
},
enabled: isOpen && isGitCheckout && Boolean(client) && isConnected,
retry: false,
staleTime: 15_000,
});
const branchOptions = useMemo<ComboboxOption[]>(() => {
const branches = branchSuggestionsQuery.data ?? [];
return branches.map((name) => ({ id: name, label: name }));
}, [branchSuggestionsQuery.data]);
const stashListQueryKey = useMemo(
() => ["stashList", normalizedServerId, normalizedWorkspaceId] as const,
[normalizedServerId, normalizedWorkspaceId],
);
const invalidateStashAndCheckout = useCallback(async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: stashListQueryKey }),
queryClient.invalidateQueries({
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
}),
]);
}, [queryClient, stashListQueryKey, normalizedServerId, normalizedWorkspaceId]);
const stashAndSwitch = useCallback(
async (branchId: string) => {
if (!client) return;
const shouldStash = await confirmDialog({
title: "Uncommitted changes",
message:
"You have uncommitted changes. Stash them before switching branches?",
confirmLabel: "Stash & Switch",
cancelLabel: "Cancel",
});
if (!shouldStash) return;
try {
const stashPayload = await client.stashSave(normalizedWorkspaceId, {
branch: currentBranchName ?? undefined,
});
if (stashPayload.error) {
toast.error(stashPayload.error.message);
return;
}
await invalidateStashAndCheckout();
const switchPayload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
if (switchPayload.error) {
toast.error(switchPayload.error.message);
return;
}
await invalidateStashAndCheckout();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to stash changes");
}
},
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, toast],
);
const handleBranchSelect = useCallback(
(branchId: string) => {
if (branchId === currentBranchName) return;
void (async () => {
if (!client) return;
try {
const payload = await client.checkoutSwitchBranch(normalizedWorkspaceId, branchId);
if (payload.error) {
// If the error is about uncommitted changes, offer the stash dialog
if (payload.error.message.toLowerCase().includes("uncommitted")) {
await stashAndSwitch(branchId);
return;
}
toast.error(payload.error.message);
return;
}
// Success — refresh and check for stashes on the target branch
await invalidateStashAndCheckout();
try {
const stashPayload = await client.stashList(normalizedWorkspaceId, { paseoOnly: true });
const targetStash = stashPayload.entries.find((e) => e.branch === branchId);
if (targetStash) {
const shouldRestore = await confirmDialog({
title: "Restore stashed changes?",
message: "This branch has stashed changes from a previous session. Would you like to restore them?",
confirmLabel: "Restore",
cancelLabel: "Later",
});
if (shouldRestore) {
const popPayload = await client.stashPop(normalizedWorkspaceId, targetStash.index);
if (popPayload.error) {
toast.error(popPayload.error.message);
} else {
toast.show("Stashed changes restored");
}
await invalidateStashAndCheckout();
}
}
} catch {
// Non-critical — user can still restore on next branch switch
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to switch branch");
}
})();
},
[client, currentBranchName, invalidateStashAndCheckout, normalizedWorkspaceId, stashAndSwitch, toast],
);
return { branchOptions, isOpen, setIsOpen, handleBranchSelect, invalidateStashAndCheckout };
}
@@ -17,6 +17,7 @@ interface UseProvidersSnapshotResult {
error: string | null;
supportsSnapshot: boolean;
refresh: () => void;
invalidate: () => void;
}
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
@@ -66,6 +67,10 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
void client.refreshProvidersSnapshot();
}, [client]);
const invalidate = useCallback(() => {
void queryClient.invalidateQueries({ queryKey });
}, [queryClient, queryKey]);
return {
entries: snapshotQuery.data?.entries ?? undefined,
isLoading: snapshotQuery.isLoading,
@@ -73,6 +78,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
supportsSnapshot,
refresh,
invalidate,
};
}