diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx
new file mode 100644
index 000000000..f17b4cdd5
--- /dev/null
+++ b/packages/app/src/components/branch-switcher.tsx
@@ -0,0 +1,125 @@
+import { useRef } from "react";
+import { Pressable, Text, View } from "react-native";
+import { ChevronDown, GitBranch } from "lucide-react-native";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
+
+interface BranchSwitcherProps {
+ currentBranchName: string | null;
+ title: string;
+ branchOptions: ComboboxOption[];
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ onBranchSelect: (branchId: string) => void;
+}
+
+export function BranchSwitcher({
+ currentBranchName,
+ title,
+ branchOptions,
+ isOpen,
+ onOpenChange,
+ onBranchSelect,
+}: BranchSwitcherProps) {
+ const { theme } = useUnistyles();
+ const anchorRef = useRef(null);
+
+ if (!currentBranchName) {
+ return (
+
+ {title}
+
+ );
+ }
+
+ return (
+
+ onOpenChange(true)}
+ style={({ hovered, pressed }) => [
+ styles.branchSwitcherTrigger,
+ (hovered || pressed) && styles.branchSwitcherTriggerHovered,
+ ]}
+ accessibilityRole="button"
+ accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
+ >
+
+
+ {title}
+
+
+
+ (
+
+ }
+ />
+ )}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ headerTitle: {
+ fontSize: theme.fontSize.base,
+ fontWeight: {
+ xs: "400",
+ md: "300",
+ },
+ color: theme.colors.foreground,
+ flexShrink: 1,
+ },
+ branchSwitcherTrigger: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ paddingVertical: theme.spacing[1],
+ paddingHorizontal: theme.spacing[2],
+ borderRadius: theme.borderRadius.md,
+ flexShrink: 1,
+ minWidth: 0,
+ },
+ branchSwitcherTriggerHovered: {
+ backgroundColor: theme.colors.surface1,
+ },
+}));
diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx
index ca1f04575..9920d660b 100644
--- a/packages/app/src/components/toast-host.tsx
+++ b/packages/app/src/components/toast-host.tsx
@@ -109,6 +109,7 @@ export function ToastViewport({
}) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
+ const isMobile = useIsCompactFormFactor();
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-8)).current;
const timeoutRef = useRef | null>(null);
@@ -182,7 +183,6 @@ export function ToastViewport({
return null;
}
- const isMobile = useIsCompactFormFactor();
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset =
diff --git a/packages/app/src/components/ui/combobox-options.test.ts b/packages/app/src/components/ui/combobox-options.test.ts
index 3c6a9addf..57396e163 100644
--- a/packages/app/src/components/ui/combobox-options.test.ts
+++ b/packages/app/src/components/ui/combobox-options.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildVisibleComboboxOptions,
+ filterAndRankComboboxOptions,
getComboboxFallbackIndex,
orderVisibleComboboxOptions,
} from "./combobox-options";
@@ -47,6 +48,48 @@ describe("buildVisibleComboboxOptions", () => {
});
});
+describe("filterAndRankComboboxOptions", () => {
+ const options = [
+ { id: "feat/login", label: "feat/login" },
+ { id: "main", label: "main" },
+ { id: "feat/main-nav", label: "feat/main-nav" },
+ { id: "fix/logout", label: "fix/logout", description: "fixes main logout bug" },
+ ];
+
+ it("returns all options when search is empty", () => {
+ expect(filterAndRankComboboxOptions(options, "")).toEqual(options);
+ });
+
+ it("filters by label substring", () => {
+ const result = filterAndRankComboboxOptions(options, "login");
+ expect(result.map((o) => o.id)).toEqual(["feat/login"]);
+ });
+
+ it("filters by id substring", () => {
+ const result = filterAndRankComboboxOptions(options, "fix/");
+ expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
+ });
+
+ it("filters by description substring", () => {
+ const result = filterAndRankComboboxOptions(options, "logout bug");
+ expect(result.map((o) => o.id)).toEqual(["fix/logout"]);
+ });
+
+ it("ranks prefix matches above substring matches", () => {
+ const result = filterAndRankComboboxOptions(options, "main");
+ expect(result.map((o) => o.id)).toEqual(["main", "feat/main-nav", "fix/logout"]);
+ });
+
+ it("is case-insensitive", () => {
+ const items = [{ id: "Alpha", label: "Alpha" }];
+ expect(filterAndRankComboboxOptions(items, "alpha")).toHaveLength(1);
+ });
+
+ it("returns empty when nothing matches", () => {
+ expect(filterAndRankComboboxOptions(options, "zzz")).toEqual([]);
+ });
+});
+
describe("combobox above-search ordering", () => {
const visible = [
{ id: "/tmp/new-project", label: "/tmp/new-project", kind: "directory" as const },
diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts
index 18a205427..8fc41832d 100644
--- a/packages/app/src/components/ui/combobox-options.ts
+++ b/packages/app/src/components/ui/combobox-options.ts
@@ -35,18 +35,33 @@ export function shouldShowCustomComboboxOption(input: {
);
}
+export function filterAndRankComboboxOptions(
+ options: ComboboxOptionModel[],
+ search: string,
+): ComboboxOptionModel[] {
+ if (!search) return options;
+ return options
+ .filter(
+ (opt) =>
+ opt.label.toLowerCase().includes(search) ||
+ opt.id.toLowerCase().includes(search) ||
+ opt.description?.toLowerCase().includes(search),
+ )
+ .sort((a, b) => {
+ const aPrefix =
+ a.label.toLowerCase().startsWith(search) || a.id.toLowerCase().startsWith(search);
+ const bPrefix =
+ b.label.toLowerCase().startsWith(search) || b.id.toLowerCase().startsWith(search);
+ if (aPrefix !== bPrefix) return aPrefix ? -1 : 1;
+ return 0;
+ });
+}
+
export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput,
): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
- const filteredOptions = normalizedSearch
- ? input.options.filter(
- (opt) =>
- opt.label.toLowerCase().includes(normalizedSearch) ||
- opt.id.toLowerCase().includes(normalizedSearch) ||
- opt.description?.toLowerCase().includes(normalizedSearch),
- )
- : input.options;
+ const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch);
const sanitizedSearchValue = input.searchQuery.trim();
const showCustomOption = shouldShowCustomComboboxOption({
diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx
index 012d181a7..7093d3b51 100644
--- a/packages/app/src/components/ui/combobox.tsx
+++ b/packages/app/src/components/ui/combobox.tsx
@@ -660,13 +660,7 @@ export function Combobox({
>
);
- const defaultContent = (
- <>
- {effectiveOptionsPosition === "above-search" ? optionsList : null}
- {searchable ? searchInput : null}
- {effectiveOptionsPosition === "below-search" ? optionsList : null}
- >
- );
+ const defaultContent = optionsList;
const content = children ?? defaultContent;
@@ -691,6 +685,7 @@ export function Combobox({
{title}
{stickyHeader}
+ {!children && searchable ? searchInput : null}
) : (
<>
+ {searchable ? searchInput : null}
{effectiveOptionsPosition === "above-search" ? (
{optionsList}
- ) : null}
- {searchable ? searchInput : null}
- {effectiveOptionsPosition === "below-search" ? (
+ ) : (
{optionsList}
- ) : null}
+ )}
>
)}
@@ -784,15 +778,12 @@ const styles = StyleSheet.create((theme) => ({
searchInputContainer: {
flexDirection: "row",
alignItems: "center",
- borderWidth: 1,
- borderColor: theme.colors.border,
- backgroundColor: theme.colors.surface1,
- borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
- marginHorizontal: theme.spacing[2],
- marginBottom: theme.spacing[2],
- marginTop: theme.spacing[1],
gap: theme.spacing[2],
+ backgroundColor: theme.colors.surface1,
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
},
searchInput: {
flex: 1,
diff --git a/packages/app/src/hooks/use-branch-switcher.ts b/packages/app/src/hooks/use-branch-switcher.ts
new file mode 100644
index 000000000..543abeb50
--- /dev/null
+++ b/packages/app/src/hooks/use-branch-switcher.ts
@@ -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;
+}
+
+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(() => {
+ 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 };
+}
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index ab53f23ac..a956b9dfd 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -34,6 +34,7 @@ import invariant from "tiny-invariant";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { ScreenHeader } from "@/components/headers/screen-header";
+import { BranchSwitcher } from "@/components/branch-switcher";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import {
@@ -80,6 +81,7 @@ import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
+import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
@@ -759,6 +761,24 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
? trimNonEmpty(checkoutQuery.data.currentBranch)
: null;
+
+ const {
+ branchOptions,
+ isOpen: isBranchSwitcherOpen,
+ setIsOpen: setIsBranchSwitcherOpen,
+ handleBranchSelect,
+ invalidateStashAndCheckout,
+ } = useBranchSwitcher({
+ client,
+ normalizedServerId,
+ normalizedWorkspaceId,
+ currentBranchName,
+ isGitCheckout,
+ isConnected,
+ toast,
+ queryClient,
+ });
+
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
@@ -1948,13 +1968,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
>
) : (
<>
-
- {workspaceHeader.title}
-
+
{
+ return this.sendCorrelatedSessionRequest({
+ requestId,
+ message: {
+ type: "checkout_switch_branch_request",
+ cwd,
+ branch,
+ },
+ responseType: "checkout_switch_branch_response",
+ timeout: 30000,
+ });
+ }
+
+ async stashSave(
+ cwd: string,
+ options?: { branch?: string },
+ requestId?: string,
+ ): Promise {
+ return this.sendCorrelatedSessionRequest({
+ requestId,
+ message: {
+ type: "stash_save_request",
+ cwd,
+ branch: options?.branch,
+ },
+ responseType: "stash_save_response",
+ timeout: 30000,
+ });
+ }
+
+ async stashPop(
+ cwd: string,
+ stashIndex: number,
+ requestId?: string,
+ ): Promise {
+ return this.sendCorrelatedSessionRequest({
+ requestId,
+ message: {
+ type: "stash_pop_request",
+ cwd,
+ stashIndex,
+ },
+ responseType: "stash_pop_response",
+ timeout: 30000,
+ });
+ }
+
+ async stashList(
+ cwd: string,
+ options?: { paseoOnly?: boolean },
+ requestId?: string,
+ ): Promise {
+ return this.sendCorrelatedSessionRequest({
+ requestId,
+ message: {
+ type: "stash_list_request",
+ cwd,
+ paseoOnly: options?.paseoOnly,
+ },
+ responseType: "stash_list_response",
+ timeout: 10000,
+ });
+ }
+
async getPaseoWorktreeList(
input: { cwd?: string; repoRoot?: string },
requestId?: string,
diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts
index 1c113e716..17e617b4c 100644
--- a/packages/server/src/server/session.ts
+++ b/packages/server/src/server/session.ts
@@ -1746,6 +1746,22 @@ export class Session {
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
+ case "checkout_switch_branch_request":
+ await this.handleCheckoutSwitchBranchRequest(msg);
+ break;
+
+ case "stash_save_request":
+ await this.handleStashSaveRequest(msg);
+ break;
+
+ case "stash_pop_request":
+ await this.handleStashPopRequest(msg);
+ break;
+
+ case "stash_list_request":
+ await this.handleStashListRequest(msg);
+ break;
+
case "checkout_commit_request":
await this.handleCheckoutCommitRequest(msg);
break;
@@ -4427,6 +4443,139 @@ export class Session {
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
}
+ private async handleCheckoutSwitchBranchRequest(
+ msg: Extract,
+ ): Promise {
+ const { cwd, branch, requestId } = msg;
+
+ try {
+ await this.checkoutExistingBranch(cwd, branch);
+ this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
+
+ // Push a workspace_update immediately so the sidebar/header reflect
+ // the new branch name without waiting for the background git watcher.
+ await this.emitWorkspaceUpdateForCwd(cwd);
+
+ this.emit({
+ type: "checkout_switch_branch_response",
+ payload: {
+ cwd,
+ success: true,
+ branch,
+ error: null,
+ requestId,
+ },
+ });
+ } catch (error) {
+ this.emit({
+ type: "checkout_switch_branch_response",
+ payload: {
+ cwd,
+ success: false,
+ branch,
+ error: toCheckoutError(error),
+ requestId,
+ },
+ });
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Stash handlers
+ // ---------------------------------------------------------------------------
+
+ private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
+
+ private async handleStashSaveRequest(
+ msg: Extract,
+ ): Promise {
+ const { cwd, requestId } = msg;
+ try {
+ const branchLabel = msg.branch?.trim() ?? "";
+ const message = branchLabel
+ ? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
+ : `${Session.PASEO_STASH_PREFIX} unnamed`;
+ await execFileAsync("git", ["stash", "push", "--include-untracked", "-m", message], { cwd });
+ this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
+ this.emit({
+ type: "stash_save_response",
+ payload: { cwd, success: true, error: null, requestId },
+ });
+ } catch (error) {
+ this.emit({
+ type: "stash_save_response",
+ payload: { cwd, success: false, error: toCheckoutError(error), requestId },
+ });
+ }
+ }
+
+ private async handleStashPopRequest(
+ msg: Extract,
+ ): Promise {
+ const { cwd, stashIndex, requestId } = msg;
+ try {
+ await execFileAsync("git", ["stash", "pop", `stash@{${stashIndex}}`], { cwd });
+ this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
+ this.emit({
+ type: "stash_pop_response",
+ payload: { cwd, success: true, error: null, requestId },
+ });
+ } catch (error) {
+ this.emit({
+ type: "stash_pop_response",
+ payload: { cwd, success: false, error: toCheckoutError(error), requestId },
+ });
+ }
+ }
+
+ private async handleStashListRequest(
+ msg: Extract,
+ ): Promise {
+ const { cwd, requestId } = msg;
+ const paseoOnly = msg.paseoOnly !== false;
+ try {
+ const { stdout } = await execAsync("git stash list --format=%gd%x00%s", {
+ cwd,
+ env: READ_ONLY_GIT_ENV,
+ });
+ const lines = stdout.trim().split("\n").filter(Boolean);
+ const entries: Array<{
+ index: number;
+ message: string;
+ branch: string | null;
+ isPaseo: boolean;
+ }> = [];
+
+ for (const line of lines) {
+ const sepIdx = line.indexOf("\0");
+ if (sepIdx < 0) continue;
+ const refPart = line.slice(0, sepIdx);
+ const subject = line.slice(sepIdx + 1);
+ const indexMatch = refPart.match(/\{(\d+)\}/);
+ if (!indexMatch) continue;
+ const index = Number(indexMatch[1]);
+ const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX);
+ const isPaseo = prefixIdx >= 0;
+ const branch = isPaseo
+ ? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null
+ : null;
+
+ if (paseoOnly && !isPaseo) continue;
+ entries.push({ index, message: subject, branch, isPaseo });
+ }
+
+ this.emit({
+ type: "stash_list_response",
+ payload: { cwd, entries, error: null, requestId },
+ });
+ } catch (error) {
+ this.emit({
+ type: "stash_list_response",
+ payload: { cwd, entries: [], error: toCheckoutError(error), requestId },
+ });
+ }
+ }
+
private async handleCheckoutCommitRequest(
msg: Extract,
): Promise {
diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts
index 1d89d5bea..bc207abf7 100644
--- a/packages/server/src/shared/messages.ts
+++ b/packages/server/src/shared/messages.ts
@@ -1057,6 +1057,37 @@ export const ValidateBranchRequestSchema = z.object({
requestId: z.string(),
});
+export const CheckoutSwitchBranchRequestSchema = z.object({
+ type: z.literal("checkout_switch_branch_request"),
+ cwd: z.string(),
+ branch: z.string(),
+ requestId: z.string(),
+});
+
+export const StashSaveRequestSchema = z.object({
+ type: z.literal("stash_save_request"),
+ cwd: z.string(),
+ /** Branch name to tag the stash with for later identification. */
+ branch: z.string().optional(),
+ requestId: z.string(),
+});
+
+export const StashPopRequestSchema = z.object({
+ type: z.literal("stash_pop_request"),
+ cwd: z.string(),
+ /** Zero-based index from stash_list_response. */
+ stashIndex: z.number().int().min(0),
+ requestId: z.string(),
+});
+
+export const StashListRequestSchema = z.object({
+ type: z.literal("stash_list_request"),
+ cwd: z.string(),
+ /** If true, only return paseo-created stashes. Default true. */
+ paseoOnly: z.boolean().optional(),
+ requestId: z.string(),
+});
+
export const BranchSuggestionsRequestSchema = z.object({
type: z.literal("branch_suggestions_request"),
cwd: z.string(),
@@ -1392,6 +1423,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema,
+ CheckoutSwitchBranchRequestSchema,
+ StashSaveRequestSchema,
+ StashPopRequestSchema,
+ StashListRequestSchema,
ValidateBranchRequestSchema,
BranchSuggestionsRequestSchema,
DirectorySuggestionsRequestSchema,
@@ -2192,6 +2227,54 @@ export const CheckoutPrStatusResponseSchema = z.object({
}),
});
+export const CheckoutSwitchBranchResponseSchema = z.object({
+ type: z.literal("checkout_switch_branch_response"),
+ payload: z.object({
+ cwd: z.string(),
+ success: z.boolean(),
+ branch: z.string(),
+ error: CheckoutErrorSchema.nullable(),
+ requestId: z.string(),
+ }),
+});
+
+const StashEntrySchema = z.object({
+ index: z.number().int().min(0),
+ message: z.string(),
+ branch: z.string().nullable(),
+ isPaseo: z.boolean(),
+});
+
+export const StashSaveResponseSchema = z.object({
+ type: z.literal("stash_save_response"),
+ payload: z.object({
+ cwd: z.string(),
+ success: z.boolean(),
+ error: CheckoutErrorSchema.nullable(),
+ requestId: z.string(),
+ }),
+});
+
+export const StashPopResponseSchema = z.object({
+ type: z.literal("stash_pop_response"),
+ payload: z.object({
+ cwd: z.string(),
+ success: z.boolean(),
+ error: CheckoutErrorSchema.nullable(),
+ requestId: z.string(),
+ }),
+});
+
+export const StashListResponseSchema = z.object({
+ type: z.literal("stash_list_response"),
+ payload: z.object({
+ cwd: z.string(),
+ entries: z.array(StashEntrySchema),
+ error: CheckoutErrorSchema.nullable(),
+ requestId: z.string(),
+ }),
+});
+
export const ValidateBranchResponseSchema = z.object({
type: z.literal("validate_branch_response"),
payload: z.object({
@@ -2578,6 +2661,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema,
+ CheckoutSwitchBranchResponseSchema,
+ StashSaveResponseSchema,
+ StashPopResponseSchema,
+ StashListResponseSchema,
ValidateBranchResponseSchema,
BranchSuggestionsResponseSchema,
DirectorySuggestionsResponseSchema,
@@ -2793,6 +2880,15 @@ export type CheckoutPrCreateRequest = z.infer;
export type CheckoutPrStatusRequest = z.infer;
export type CheckoutPrStatusResponse = z.infer;
+export type CheckoutSwitchBranchRequest = z.infer;
+export type CheckoutSwitchBranchResponse = z.infer;
+export type StashSaveRequest = z.infer;
+export type StashSaveResponse = z.infer;
+export type StashPopRequest = z.infer;
+export type StashPopResponse = z.infer;
+export type StashListRequest = z.infer;
+export type StashListResponse = z.infer;
+export type StashEntry = z.infer;
export type ValidateBranchRequest = z.infer;
export type ValidateBranchResponse = z.infer;
export type BranchSuggestionsRequest = z.infer;
diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts
index eb71a098b..ccfb38bde 100644
--- a/packages/server/src/utils/checkout-git.ts
+++ b/packages/server/src/utils/checkout-git.ts
@@ -157,7 +157,6 @@ type CheckoutFileChange = {
isUntracked?: boolean;
};
-type BranchSuggestionRefOrigin = "local" | "remote";
function normalizeBranchSuggestionName(raw: string): string | null {
const trimmed = raw.trim();
@@ -176,47 +175,57 @@ function normalizeBranchSuggestionName(raw: string): string | null {
normalized = normalized.slice("origin/".length);
}
- if (!normalized || normalized === "HEAD") {
+ if (!normalized || normalized === "HEAD" || normalized === "origin") {
return null;
}
return normalized;
}
-async function listGitRefs(cwd: string, refPrefix: string): Promise {
- const { stdout } = await execGit(`git for-each-ref --format="%(refname:short)" ${refPrefix}`, {
- cwd,
- env: READ_ONLY_GIT_ENV,
- });
+interface GitRef {
+ name: string;
+ committerDate: number;
+}
+
+async function listGitRefs(cwd: string, refPrefix: string): Promise {
+ const { stdout } = await execGit(
+ `git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`,
+ { cwd, env: READ_ONLY_GIT_ENV },
+ );
return stdout
.split("\n")
- .map((line) => line.trim())
- .filter((line) => line.length > 0);
+ .map((line) => {
+ const trimmed = line.trim();
+ if (!trimmed) return null;
+ const [name, dateStr] = trimmed.split("\t");
+ if (!name) return null;
+ return { name, committerDate: Number(dateStr) || 0 };
+ })
+ .filter((ref): ref is GitRef => ref !== null);
}
function sortBranchSuggestions(
branchNames: string[],
- localBranchNames: Set,
+ branchMeta: Map,
query: string,
): string[] {
const normalizedQuery = query.trim().toLowerCase();
const hasQuery = normalizedQuery.length > 0;
return branchNames.sort((a, b) => {
- const aLower = a.toLowerCase();
- const bLower = b.toLowerCase();
-
if (hasQuery) {
- const aPrefix = aLower.startsWith(normalizedQuery);
- const bPrefix = bLower.startsWith(normalizedQuery);
+ const aPrefix = a.toLowerCase().startsWith(normalizedQuery);
+ const bPrefix = b.toLowerCase().startsWith(normalizedQuery);
if (aPrefix !== bPrefix) {
return aPrefix ? -1 : 1;
}
}
- const aIsLocal = localBranchNames.has(a);
- const bIsLocal = localBranchNames.has(b);
- if (aIsLocal !== bIsLocal) {
- return aIsLocal ? -1 : 1;
+ const aMeta = branchMeta.get(a);
+ const bMeta = branchMeta.get(b);
+ const aDate = aMeta?.committerDate ?? 0;
+ const bDate = bMeta?.committerDate ?? 0;
+ if (aDate !== bDate) {
+ return bDate - aDate;
}
return a.localeCompare(b);
@@ -238,41 +247,40 @@ export async function listBranchSuggestions(
listGitRefs(cwd, "refs/remotes/origin"),
]);
- const merged = new Map>();
- for (const localRef of localRefs) {
- const normalized = normalizeBranchSuggestionName(localRef);
- if (!normalized) {
- continue;
- }
- const origins = merged.get(normalized) ?? new Set();
- origins.add("local");
- merged.set(normalized, origins);
- }
- for (const remoteRef of remoteRefs) {
- const normalized = normalizeBranchSuggestionName(remoteRef);
- if (!normalized) {
- continue;
- }
- const origins = merged.get(normalized) ?? new Set();
- origins.add("remote");
- merged.set(normalized, origins);
+ const branchMeta = new Map();
+
+ for (const ref of localRefs) {
+ const normalized = normalizeBranchSuggestionName(ref.name);
+ if (!normalized) continue;
+ const existing = branchMeta.get(normalized);
+ branchMeta.set(normalized, {
+ isLocal: true,
+ committerDate: Math.max(ref.committerDate, existing?.committerDate ?? 0),
+ });
}
- const filteredNames = Array.from(merged.keys()).filter((name) =>
+ for (const ref of remoteRefs) {
+ const normalized = normalizeBranchSuggestionName(ref.name);
+ if (!normalized) continue;
+ const existing = branchMeta.get(normalized);
+ if (!existing) {
+ branchMeta.set(normalized, { isLocal: false, committerDate: ref.committerDate });
+ } else {
+ branchMeta.set(normalized, {
+ ...existing,
+ committerDate: Math.max(ref.committerDate, existing.committerDate),
+ });
+ }
+ }
+
+ const filteredNames = Array.from(branchMeta.keys()).filter((name) =>
query ? name.toLowerCase().includes(query) : true,
);
if (filteredNames.length === 0) {
return [];
}
- const localBranchNames = new Set();
- for (const [name, origins] of merged) {
- if (origins.has("local")) {
- localBranchNames.add(name);
- }
- }
-
- const ordered = sortBranchSuggestions(filteredNames, localBranchNames, query);
+ const ordered = sortBranchSuggestions(filteredNames, branchMeta, query);
return ordered.slice(0, limit);
}