diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afb4acdf6..e0dc3cc4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,26 @@ Thanks for taking the time to contribute. +## How this project works + +Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call. + +This means: + +- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down. +- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion. +- There is no obligation to merge a PR as-submitted, regardless of code quality. + +This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time. + +## How to contribute + +1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code. +2. **Keep it small.** One bug, one flow, one focused change. +3. **Open a PR** once there is alignment on scope. + +If you want to propose a direction change, start a conversation. + ## Before you start Please read these first: @@ -15,26 +35,16 @@ Please read these first: ## What is most helpful -The highest-signal contributions right now are: +The most useful contributions right now are: - bug fixes +- windows and linux specific fixes - regression fixes -- docs improvements +- doc improvements - packaging / platform fixes - focused UX improvements that fit the existing product direction - tests that lock down important behavior -## Discuss large changes first - -If you want to add a major feature, change core UX, introduce a new surface, or bring in a new architectural concept, please open an issue or start a conversation first. - -Even if the code is good, large unsolicited PRs are unlikely to be merged if they set product direction without prior alignment. - -In short: - -- small, focused PRs: great -- large product-shaping PRs without discussion: probably not - ## Scope expectations Please keep PRs narrow. @@ -66,8 +76,10 @@ When reviewing contributions, the bar is not just: It is also: - does this fit Paseo? +- does this add product surface that will be hard to maintain? +- does the value justify the maintenance surface it adds? +- does this solve a common need or over-serve an edge case? - does this preserve the product's current direction? -- does this increase long-term complexity in a way that is worth it? ## Development setup @@ -79,6 +91,7 @@ It is also: ### Start local development ```bash +# runs both daemon and expo app npm run dev ``` @@ -94,9 +107,9 @@ npm run cli -- ls -a -g Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details. -## Testing and verification +## Multi-platform testing -At minimum, run the checks relevant to your change. +Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another. Common checks: @@ -117,16 +130,7 @@ If you touch protocol or shared client/server behavior, read the compatibility n ## Coding standards -Paseo has explicit standards. Please follow them. - -Highlights: - -- keep complexity low -- avoid "while I'm at it" cleanup -- no `any` -- prefer object parameters over positional argument lists -- preserve behavior unless the change is explicitly meant to change behavior -- collocate tests with implementation +Paseo has explicit standards. Follow them. The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md). @@ -134,12 +138,14 @@ The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md). Before opening a PR, make sure: -- the change is focused +- there was prior discussion and alignment on scope (issue or conversation) +- the change is focused, one idea per PR - the PR description explains what changed and why -- relevant docs were updated if needed +- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop) +- UI changes have been tested on mobile and web at minimum - typecheck passes - tests pass, or you clearly explain what could not be run -- the change does not accidentally bundle unrelated product ideas +- relevant docs were updated if needed ## Communication @@ -153,7 +159,7 @@ That is especially true for: - new orchestration models - anything that would be hard to remove later -Early alignment is much better than a large PR that is expensive for everyone to unwind. +Early alignment saves everyone time. ## Forks are fine diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index cc2f22917..402e1ca40 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -88,6 +88,7 @@ type ControlledAgentStatusBarProps = { features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; onDropdownClose?: () => void; + onModelSelectorOpen?: () => void; }; export interface DraftAgentStatusBarProps { @@ -110,6 +111,7 @@ export interface DraftAgentStatusBarProps { features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; onDropdownClose?: () => void; + onModelSelectorOpen?: () => void; disabled?: boolean; } @@ -217,6 +219,7 @@ function ControlledStatusBar({ features, onSetFeature, onDropdownClose, + onModelSelectorOpen, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); const isWeb = Platform.OS === "web"; @@ -411,6 +414,7 @@ function ControlledStatusBar({ onToggleFavorite={onToggleFavoriteModel} isLoading={isModelLoading} disabled={modelDisabled} + onOpen={onModelSelectorOpen} onClose={onDropdownClose} /> @@ -662,6 +666,7 @@ function ControlledStatusBar({ onToggleFavorite={onToggleFavoriteModel} isLoading={isModelLoading} disabled={modelDisabled} + onOpen={onModelSelectorOpen} onClose={onDropdownClose} renderTrigger={({ selectedModelLabel }) => ( { @@ -1035,6 +1041,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat }); }} isModelLoading={snapshotIsLoading || snapshotIsFetching} + onModelSelectorOpen={invalidateSnapshot} onDropdownClose={onDropdownClose} disabled={!client} /> @@ -1061,6 +1068,7 @@ export function DraftAgentStatusBar({ features, onSetFeature, onDropdownClose, + onModelSelectorOpen, disabled = false, }: DraftAgentStatusBarProps) { const isWeb = Platform.OS === "web"; @@ -1105,6 +1113,7 @@ export function DraftAgentStatusBar({ }} isLoading={isAllModelsLoading} disabled={disabled} + onOpen={onModelSelectorOpen} onClose={onDropdownClose} /> 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/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index bac0ac232..60e9e48a0 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -59,6 +59,7 @@ interface CombinedModelSelectorProps { disabled: boolean; isOpen: boolean; }) => React.ReactNode; + onOpen?: () => void; onClose?: () => void; disabled?: boolean; } @@ -517,6 +518,7 @@ export function CombinedModelSelector({ favoriteKeys = new Set(), onToggleFavorite, renderTrigger, + onOpen, onClose, disabled = false, }: CombinedModelSelectorProps) { @@ -553,12 +555,14 @@ export function CombinedModelSelector({ (open: boolean) => { setIsOpen(open); setView(computeInitialView()); - if (!open) { + if (open) { + onOpen?.(); + } else { setSearchQuery(""); onClose?.(); } }, - [onClose, computeInitialView], + [onOpen, onClose, computeInitialView], ); const handleSelect = useCallback( 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/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 4261f60a2..f871958c7 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -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; @@ -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, diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 8550aa2bd..00d8dec50 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -159,6 +159,7 @@ function buildDraftStatusControls(input: { features, onSetFeature, onDropdownClose, + onModelSelectorOpen: formState.invalidateProviderModels, }; } 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/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index ffe23fa78..afee8096b 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -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, }; } diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index 188fb9e9c..f70ce792c 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -38,6 +38,28 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "flex-start", paddingTop: theme.spacing[16], }, + errorScreen: { + position: "relative", + flex: 1, + backgroundColor: theme.colors.surface0, + }, + errorScrollView: { + flex: 1, + ...(Platform.OS === "web" + ? { + overflowX: "auto", + overflowY: "auto", + } + : null), + }, + errorScrollContent: { + flexGrow: 1, + alignItems: "center", + justifyContent: "flex-start", + paddingHorizontal: theme.spacing[8], + paddingVertical: theme.spacing[8], + paddingTop: theme.spacing[16], + }, centeredContent: { alignItems: "center", justifyContent: "center", @@ -255,67 +277,73 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps } return ( - + - - - - Something went wrong + + + + + Something went wrong + + + + The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below. + + + + {bootstrapState.error} + + + {daemonLogs?.logPath ? {daemonLogs.logPath} : null} + + + + + {logsText} + + + + + + + + + + - - - The local server failed to start. If this keeps happening, please report the issue on GitHub and include the logs below. - - - - {bootstrapState.error} - - - {daemonLogs?.logPath ? {daemonLogs.logPath} : null} - - - - - {logsText} - - - - - - - - - - - + ); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a2990e549..f2d5c9b04 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -36,6 +36,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 { @@ -82,6 +83,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"; @@ -786,6 +788,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); @@ -2018,13 +2038,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) ) : ( <> - - {workspaceHeader.title} - + { ).toBe("attention"); }); - it("treats initializing agents as running", () => { + it("treats initializing agents as done", () => { expect( deriveSidebarStateBucket({ status: "initializing", @@ -43,6 +43,6 @@ describe("deriveSidebarStateBucket", () => { requiresAttention: false, attentionReason: null, }), - ).toBe("running"); + ).toBe("done"); }); }); diff --git a/packages/app/src/utils/sidebar-agent-state.ts b/packages/app/src/utils/sidebar-agent-state.ts index 21f07313a..d1e77b9ed 100644 --- a/packages/app/src/utils/sidebar-agent-state.ts +++ b/packages/app/src/utils/sidebar-agent-state.ts @@ -21,7 +21,7 @@ export function deriveSidebarStateBucket(input: { if (input.status === "error" || input.attentionReason === "error") { return "failed"; } - if (input.status === "running" || input.status === "initializing") { + if (input.status === "running") { return "running"; } if (input.requiresAttention) { diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index aa7695d29..e32996a0c 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -28,6 +28,10 @@ import type { CheckoutPushResponse, CheckoutPrCreateResponse, CheckoutPrStatusResponse, + CheckoutSwitchBranchResponse, + StashSaveResponse, + StashPopResponse, + StashListResponse, ValidateBranchResponse, BranchSuggestionsResponse, GitHubSearchResponse, @@ -224,6 +228,10 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; +type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"]; +type StashSavePayload = StashSaveResponse["payload"]; +type StashPopPayload = StashPopResponse["payload"]; +type StashListPayload = StashListResponse["payload"]; type ValidateBranchPayload = ValidateBranchResponse["payload"]; type BranchSuggestionsPayload = BranchSuggestionsResponse["payload"]; type GitHubSearchPayload = GitHubSearchResponse["payload"]; @@ -2420,6 +2428,74 @@ export class DaemonClient { }); } + async checkoutSwitchBranch( + cwd: string, + branch: string, + requestId?: string, + ): Promise { + 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/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 615fa0bd7..f3f594860 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -302,7 +302,7 @@ export async function createPaseoDaemon( app.use((req, res, next) => { const origin = req.headers.origin; - if (origin && allowedOrigins.has(origin)) { + if (origin && (allowedOrigins.has("*") || allowedOrigins.has(origin))) { res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3d8e8b8fa..cdc4c8ee0 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1664,6 +1664,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; @@ -4404,6 +4420,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 { @@ -5311,7 +5460,7 @@ export class Session { if (agent.status === "error" || agent.attentionReason === "error") { return "failed"; } - if (agent.status === "running" || agent.status === "initializing") { + if (agent.status === "running") { return "running"; } if (agent.requiresAttention) { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index faa605078..9e37a19f0 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -425,7 +425,7 @@ export class VoiceAssistantWebSocketServer { !!requestHost && (origin === `http://${requestHost}` || origin === `https://${requestHost}`); - if (!origin || allowedOrigins.has(origin) || sameOrigin) { + if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) { callback(true); } else { this.incrementRuntimeCounter("originRejected"); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 1730ba4e3..1b16d660a 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1066,6 +1066,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(), @@ -1436,6 +1467,10 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPushRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrStatusRequestSchema, + CheckoutSwitchBranchRequestSchema, + StashSaveRequestSchema, + StashPopRequestSchema, + StashListRequestSchema, ValidateBranchRequestSchema, BranchSuggestionsRequestSchema, GitHubSearchRequestSchema, @@ -2343,6 +2378,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({ @@ -2745,6 +2828,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPushResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrStatusResponseSchema, + CheckoutSwitchBranchResponseSchema, + StashSaveResponseSchema, + StashPopResponseSchema, + StashListResponseSchema, ValidateBranchResponseSchema, BranchSuggestionsResponseSchema, GitHubSearchResponseSchema, @@ -2973,6 +3060,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 9ef16a277..ed362216c 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -191,7 +191,6 @@ type CheckoutFileChange = { isUntracked?: boolean; }; -type BranchSuggestionRefOrigin = "local" | "remote"; function normalizeBranchSuggestionName(raw: string): string | null { const trimmed = raw.trim(); @@ -210,47 +209,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); @@ -272,41 +281,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); } diff --git a/scripts/dev.sh b/scripts/dev.sh index 3ddc9104e..86b7d9edf 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -39,7 +39,10 @@ echo "════════════════════════ # through the daemon's Portless URL instead of a fixed localhost port. APP_ORIGIN="$(portless get app)" DAEMON_ENDPOINT="$(portless get daemon | sed -E 's#^https?://##')" -export PASEO_CORS_ORIGINS="${APP_ORIGIN}" +# Allow any origin in dev so Electron on random ports and Portless URLs all work. +# SECURITY: wildcard CORS is unsafe in production — only acceptable here because +# the daemon binds to localhost and this script is never used for production. +export PASEO_CORS_ORIGINS="*" # Run both with concurrently # BROWSER=none prevents auto-opening browser