From 34bd8dfd1bdef1b85a29ccc6ac9f45b5ea6f5887 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 10:29:34 +0700 Subject: [PATCH 01/15] Stable PASEO_HOME for worktrees and shared speech models in dev.sh Worktrees get a persistent ~/.paseo- home instead of a temp dir. Speech models point at ~/.paseo/models/local-speech to avoid re-downloads. --- scripts/dev.sh | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/dev.sh b/scripts/dev.sh index d4e8426de..3ddc9104e 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -5,17 +5,34 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export PATH="$SCRIPT_DIR/../node_modules/.bin:$PATH" -# Use a temporary PASEO_HOME to avoid conflicts between dev instances +# Derive PASEO_HOME: stable name for worktrees, temporary dir otherwise if [ -z "${PASEO_HOME}" ]; then export PASEO_HOME - PASEO_HOME="$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")" - trap "rm -rf '$PASEO_HOME'" EXIT + GIT_DIR="$(git rev-parse --git-dir 2>/dev/null || true)" + GIT_COMMON_DIR="$(git rev-parse --git-common-dir 2>/dev/null || true)" + if [ -n "$GIT_DIR" ] && [ -n "$GIT_COMMON_DIR" ] && [ "$GIT_DIR" != "$GIT_COMMON_DIR" ]; then + # Inside a worktree — derive a stable home from the worktree name + WORKTREE_ROOT="$(git rev-parse --show-toplevel)" + WORKTREE_NAME="$(basename "$WORKTREE_ROOT" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g; s/--*/-/g; s/^-//; s/-$//')" + PASEO_HOME="$HOME/.paseo-${WORKTREE_NAME}" + mkdir -p "$PASEO_HOME" + else + PASEO_HOME="$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")" + trap "rm -rf '$PASEO_HOME'" EXIT + fi +fi + +# Share speech models with the main install to avoid duplicate downloads +if [ -z "${PASEO_LOCAL_MODELS_DIR}" ]; then + export PASEO_LOCAL_MODELS_DIR="$HOME/.paseo/models/local-speech" + mkdir -p "$PASEO_LOCAL_MODELS_DIR" fi echo "══════════════════════════════════════════════════════" echo " Paseo Dev" echo "══════════════════════════════════════════════════════" echo " Home: ${PASEO_HOME}" +echo " Models: ${PASEO_LOCAL_MODELS_DIR}" echo "══════════════════════════════════════════════════════" # Configure the daemon for the Portless app origin and let the app bootstrap From edd5503fe8fdc87c2b698e3204aa1eaa796d4bd3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 10:35:48 +0700 Subject: [PATCH 02/15] Support wildcard CORS origin via PASEO_CORS_ORIGINS=* --- packages/server/src/server/bootstrap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index ada95fea3..e06ac834d 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -254,7 +254,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"); From a692c616cbdc69b57e00e2911da412f839f1f619 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 10:45:10 +0700 Subject: [PATCH 03/15] Fix error screen not scrollable on Electron --- .../app/src/screens/startup-splash-screen.tsx | 146 +++++++++++------- 1 file changed, 87 insertions(+), 59 deletions(-) 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} - - - - - - - - - - - + ); } From 32fe4b3beb8cc6215560f49b5a89d4bd622dbf70 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 10:45:36 +0700 Subject: [PATCH 04/15] Support wildcard CORS in WebSocket verifyClient and dev.sh --- packages/server/src/server/websocket-server.ts | 2 +- scripts/dev.sh | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7c719b454..2a90a7464 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -396,7 +396,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/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 From 1d41a50e1ff50b6569785328e9a91c77a3afb1dc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 11:01:01 +0700 Subject: [PATCH 05/15] Background revalidate provider models when model selector opens On fresh startup the initial provider snapshot request can return before providers finish loading, leaving the model picker empty. The server broadcasts updates once providers are ready, but if the user opens the picker in the interim they see "No models match". Invalidate the React Query cache (background refetch, no loading flash) every time the model selector popup opens so stale data is silently refreshed. --- packages/app/src/components/agent-status-bar.tsx | 10 ++++++++++ .../app/src/components/combined-model-selector.tsx | 8 ++++++-- packages/app/src/hooks/use-agent-form-state.ts | 8 ++++++++ packages/app/src/hooks/use-providers-snapshot.ts | 6 ++++++ packages/app/src/screens/agent/draft-agent-screen.tsx | 2 ++ .../screens/workspace/workspace-draft-agent-tab.tsx | 2 ++ 6 files changed, 34 insertions(+), 2 deletions(-) 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/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 745e0369a..c3dbdea3c 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) { @@ -541,12 +543,14 @@ export function CombinedModelSelector({ (open: boolean) => { setIsOpen(open); setView(singleProviderView ?? { kind: "all" }); - if (!open) { + if (open) { + onOpen?.(); + } else { setSearchQuery(""); onClose?.(); } }, - [onClose, singleProviderView], + [onOpen, onClose, singleProviderView], ); const handleSelect = useCallback( diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index fcf939101..e9fa25965 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 @@ type UseAgentFormStateResult = { isModelLoading: boolean; modelError: string | null; refreshProviderModels: () => void; + invalidateProviderModels: () => void; setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void; workingDirIsEmpty: boolean; persistFormPreferences: () => Promise; @@ -375,6 +376,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg isFetching: snapshotIsFetching, error: snapshotError, refresh: refreshSnapshot, + invalidate: invalidateSnapshot, } = useProvidersSnapshot(formState.serverId); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); @@ -648,6 +650,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; @@ -714,6 +720,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg isModelLoading, modelError, refreshProviderModels, + invalidateProviderModels, setProviderAndModelFromUser, workingDirIsEmpty, persistFormPreferences, @@ -745,6 +752,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg isModelLoading, modelError, refreshProviderModels, + invalidateProviderModels, setProviderAndModelFromUser, workingDirIsEmpty, persistFormPreferences, 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/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 534bf759b..696cd3705 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -226,6 +226,7 @@ function DraftAgentScreenContent({ isModelLoading, modelError, refreshProviderModels, + invalidateProviderModels, setProviderAndModelFromUser, persistFormPreferences, } = useAgentFormState({ @@ -1268,6 +1269,7 @@ function DraftAgentScreenContent({ onSelectThinkingOption: setThinkingOptionFromUser, features: draftFeatures, onSetFeature: setDraftFeatureValue, + onModelSelectorOpen: invalidateProviderModels, disabled: isSubmitting, }} /> diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index c681c62eb..84a559cea 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -80,6 +80,7 @@ export function WorkspaceDraftAgentTab({ availableThinkingOptions, isModelLoading, setProviderAndModelFromUser, + invalidateProviderModels, persistFormPreferences, } = useAgentFormState({ initialServerId: serverId, @@ -373,6 +374,7 @@ export function WorkspaceDraftAgentTab({ features: draftFeatures, onSetFeature: handleSetFeatureWithFocus, onDropdownClose: () => focusInputRef.current?.(), + onModelSelectorOpen: invalidateProviderModels, disabled: isSubmitting, }} /> From 8a9d738438077a2f8a5afcbac58931f3d6ce67cc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 11:13:14 +0700 Subject: [PATCH 06/15] Fix sync loader spinner showing for initializing (non-running) agents Initializing agents were mapped to the "running" bucket in both server and client, causing the SyncedLoader spinner to appear in tabs and sidebar workspace rows when opening an agent for the first time. --- packages/app/src/utils/sidebar-agent-state.test.ts | 4 ++-- packages/app/src/utils/sidebar-agent-state.ts | 2 +- packages/server/src/server/session.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/utils/sidebar-agent-state.test.ts b/packages/app/src/utils/sidebar-agent-state.test.ts index 74f02eba8..bd62055c8 100644 --- a/packages/app/src/utils/sidebar-agent-state.test.ts +++ b/packages/app/src/utils/sidebar-agent-state.test.ts @@ -35,7 +35,7 @@ describe("deriveSidebarStateBucket", () => { ).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/server/session.ts b/packages/server/src/server/session.ts index eafa8e6eb..1c113e716 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5322,7 +5322,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) { From b5a0ee99546dfe8fdb470c16a8f915f221dcdc03 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 13:26:04 +0700 Subject: [PATCH 07/15] feat: branch switching with stash-and-switch (#231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add branch switching and stash management to workspace header Adds the ability to switch git branches from the workspace header by clicking the branch name. Includes full stash support: when switching away from a dirty branch, users are prompted to stash changes; when switching to a branch with a Paseo stash, they're prompted to restore. An amber stash indicator in the header provides anytime restore/discard. New WebSocket messages: checkout_switch_branch, stash_save, stash_pop, stash_drop, stash_list. Stashes are tracked via git stash messages with a "paseo-auto-stash:" prefix convention. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: await query invalidation after branch switch and stash The branch switcher became non-clickable after switching because checkout query invalidation was fire-and-forget. Now we await invalidation so the UI has fresh checkout status (including the new branch name) before rendering. Also invalidate after stash-save so the checkout query sees clean state before the switch. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: emit workspace update immediately after branch switch The sidebar and header were slow to reflect the new branch name because they relied on the daemon's background git watcher to detect changes. Now the switch handler calls emitWorkspaceUpdateForCwd immediately after checkout, pushing the updated workspace descriptor to connected clients. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: stash detection and dirty-tree branch switch handling - Fix stash list parser: git prepends "On : " to stash messages, so use indexOf instead of startsWith to find the paseo-auto-stash prefix. - Remove switchBranchMutation in favor of inline async flow in handleBranchSelect. Now tries the switch first; if the server returns an uncommitted-changes error, offers the stash dialog automatically — no more red error toast on first attempt. - Move post-switch stash restore prompt into handleBranchSelect so the full flow (switch → check stashes → prompt) is sequential. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: toast.success → toast.show and await stash invalidation - Replace toast.success() with toast.show() — the ToastApi only exposes show/copied/error, not success. - Await invalidateStashAndCheckout in stashPop and stashDrop mutation onSuccess handlers so the stash indicator disappears immediately after restore/discard. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: add stash diff preview and multi-stash management - Add stash_show message pair: server runs `git stash show -p` and returns ParsedDiffFile[] through the existing diff parser. - Stash indicator dropdown now lists ALL Paseo stashes (not just current branch) with per-stash Preview, Restore, and Discard actions. - Preview opens a full diff modal showing file-by-file changes with syntax-colored add/remove lines, file stats, and Restore/Discard action buttons. - Clean up: remove unused handleRestoreStash/handleDropStash callbacks, add Eye/Trash2 icons, import Modal/ScrollView. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: use Fonts.mono instead of theme.fontFamily.mono The theme object doesn't have a fontFamily property. The codebase uses the Fonts constant from @/constants/theme for font family values. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: filter stashes to current branch and handle restore conflicts - Stash indicator only shows when the current branch has stashes, not all branches. Switching branches hides/shows it correctly. - When restoring a stash conflicts with uncommitted changes, offers to stash current changes first then restore the target stash (auto-adjusts stash index after the new save). - Multiple stashes on the same branch show as "Stash 1", "Stash 2" instead of repeating the branch name. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: improve branch switcher with recency sort, sticky search, and branch icons - Sort branches by committer date (most recent first) instead of alphabetical - Use full refname to fix normalization of branches with slashes - Filter out bare "origin" symref leaking as a branch name - Remove server-side search query; fetch all branches once and filter client-side - Port combobox sticky search design from dev (borderless bar above scroll area) - Add GitBranch icons to branch switcher items - Prioritize prefix matches in combobox search results * fix: move useIsCompactFormFactor hook above early return in ToastViewport The hook was called after `if (!toast) return null`, violating React's rules of hooks and causing "Rendered more hooks than during the previous render" crash. * fix: include untracked files in stash-and-switch flow git stash push without --include-untracked left untracked files behind, causing the clean-tree check to fail on the subsequent checkout. * refactor: strip stash management UI, keep stash-and-switch flow Remove the stash indicator icon, dropdown menu, preview modal, and stash_drop/stash_show RPCs. Keep the auto-stash on branch switch and the restore prompt on arrival — that's the useful surface. * refactor: extract filterAndRankComboboxOptions with tests * refactor: extract BranchSwitcher component and useBranchSwitcher hook --------- Co-authored-by: heyimsteve <8645831+heyimsteve@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- .../app/src/components/branch-switcher.tsx | 125 +++++++++++++ packages/app/src/components/toast-host.tsx | 2 +- .../components/ui/combobox-options.test.ts | 43 +++++ .../app/src/components/ui/combobox-options.ts | 31 +++- packages/app/src/components/ui/combobox.tsx | 27 +-- packages/app/src/hooks/use-branch-switcher.ts | 164 ++++++++++++++++++ .../screens/workspace/workspace-screen.tsx | 35 +++- packages/server/src/client/daemon-client.ts | 76 ++++++++ packages/server/src/server/session.ts | 149 ++++++++++++++++ packages/server/src/shared/messages.ts | 96 ++++++++++ packages/server/src/utils/checkout-git.ts | 100 ++++++----- 11 files changed, 768 insertions(+), 80 deletions(-) create mode 100644 packages/app/src/components/branch-switcher.tsx create mode 100644 packages/app/src/hooks/use-branch-switcher.ts 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); } From ebfad945e53329ea20588f14af4446c6d117aa97 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 13:47:36 +0700 Subject: [PATCH 08/15] docs: add CONTRIBUTING.md with BDFL expectations and PR requirements --- CONTRIBUTING.md | 168 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..9ae251059 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,168 @@ +# Contributing to Paseo + +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: + +- [README.md](README.md) +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) +- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) +- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) +- [docs/TESTING.md](docs/TESTING.md) +- [CLAUDE.md](CLAUDE.md) + +## What is most helpful + +The highest-signal contributions right now are: + +- bug fixes +- windows and linux specific fixes +- regression fixes +- doc improvements +- packaging / platform fixes +- focused UX improvements that fit the existing product direction +- tests that lock down important behavior + +## Scope expectations + +Please keep PRs narrow. + +Good: + +- fix one bug +- improve one flow +- add one focused panel or command +- tighten one piece of UI + +Bad: + +- combine multiple product ideas in one PR +- bundle unrelated refactors with a feature +- sneak in roadmap decisions + +If a contribution contains multiple ideas, split it up. + +## Product fit matters + +Paseo is an opinionated product. + +When reviewing contributions, the bar is not just: + +- is this useful? +- is this well implemented? + +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? + +## Development setup + +### Prerequisites + +- Node.js matching `.tool-versions` +- npm workspaces + +### Start local development + +```bash +# runs both daemon and expo app +npm run dev +``` + +Useful commands: + +```bash +npm run dev:server +npm run dev:app +npm run dev:desktop +npm run dev:website +npm run cli -- ls -a -g +``` + +Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details. + +## Multi-platform testing + +Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop too if relevant. This is a multi-platform codebase and things that look fine on one surface regularly break on another. + +Common checks: + +```bash +npm run typecheck +npm run test --workspaces --if-present +``` + +Important rules: + +- always run `npm run typecheck` after changes +- tests should be deterministic +- prefer real dependencies over mocks when possible +- do not make breaking WebSocket / protocol changes +- app and daemon versions in the wild lag each other, so compatibility matters + +If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md). + +## Coding standards + +Paseo has explicit standards. Please follow them. + +The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md). + +## PR checklist + +Before opening a PR, make sure: + +- 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 +- **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 +- relevant docs were updated if needed + +## Communication + +If you are unsure whether something fits, ask first. + +That is especially true for: + +- new core UX +- naming / terminology changes +- new extension points +- 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. + +## Forks are fine + +If you want to explore a different product direction, a fork is completely fine. + +Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable. From 0e7dfcaf2aa18d0fae958b102a0522d0dd674cd1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 13:59:54 +0700 Subject: [PATCH 09/15] docs: clean up CONTRIBUTING.md wording --- CONTRIBUTING.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ae251059..e0dc3cc4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ This means: - 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. +This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time. ## How to contribute @@ -35,7 +35,7 @@ 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 @@ -109,7 +109,7 @@ Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local st ## Multi-platform testing -Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop too if relevant. This is a multi-platform codebase and things that look fine on one surface regularly break on another. +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: @@ -130,7 +130,7 @@ If you touch protocol or shared client/server behavior, read the compatibility n ## Coding standards -Paseo has explicit standards. Please follow them. +Paseo has explicit standards. Follow them. The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md). @@ -139,7 +139,7 @@ The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md). Before opening a PR, make sure: - there was prior discussion and alignment on scope (issue or conversation) -- the change is focused — one idea per PR +- the change is focused, one idea per PR - the PR description explains what changed and why - **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop) - UI changes have been tested on mobile and web at minimum @@ -159,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 From 0f005172ce9242433e2caba7fe5fe58b6b612566 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 14:42:39 +0700 Subject: [PATCH 10/15] chore: use build:daemon in worktree setup --- paseo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paseo.json b/paseo.json index 68dd0e929..7f0d5ad8a 100644 --- a/paseo.json +++ b/paseo.json @@ -2,7 +2,7 @@ "worktree": { "setup": [ "npm ci", - "npm run build --workspace=@getpaseo/relay", + "npm run build:daemon", "cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\"" ], "terminals": [ From 033c4bcbf21ff6d13ba7f5388d4a45ab1217edf8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 15:11:14 +0700 Subject: [PATCH 11/15] fix: let xterm.js handle keyboard input natively for scroll-on-input Only intercept keys when mobile virtual modifier buttons are active. Previously all special keys (Enter, Ctrl+C, arrows) were intercepted and manually re-encoded, bypassing xterm's built-in scrollOnUserInput. --- packages/app/src/utils/terminal-keys.test.ts | 18 +++++++++++++----- packages/app/src/utils/terminal-keys.ts | 7 +------ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts index 4ea84d0af..2a627d587 100644 --- a/packages/app/src/utils/terminal-keys.test.ts +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -52,7 +52,7 @@ describe("terminal key helpers", () => { }); }); - it("intercepts special keys and modifier combos", () => { + it("only intercepts when pending modifiers are active", () => { expect( shouldInterceptDomTerminalKey({ key: "Escape", @@ -60,7 +60,7 @@ describe("terminal key helpers", () => { altKey: false, pendingModifiers: { ctrl: false, shift: false, alt: false }, }), - ).toBe(true); + ).toBe(false); expect( shouldInterceptDomTerminalKey({ key: "c", @@ -68,15 +68,23 @@ describe("terminal key helpers", () => { altKey: false, pendingModifiers: { ctrl: false, shift: false, alt: false }, }), - ).toBe(true); + ).toBe(false); expect( shouldInterceptDomTerminalKey({ key: "c", ctrlKey: false, altKey: false, - pendingModifiers: { ctrl: false, shift: false, alt: false }, + pendingModifiers: { ctrl: true, shift: false, alt: false }, }), - ).toBe(false); + ).toBe(true); + expect( + shouldInterceptDomTerminalKey({ + key: "Escape", + ctrlKey: false, + altKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: true }, + }), + ).toBe(true); }); it("detects pending modifier state", () => { diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts index aad38de42..55c7c523a 100644 --- a/packages/app/src/utils/terminal-keys.ts +++ b/packages/app/src/utils/terminal-keys.ts @@ -88,12 +88,7 @@ export function shouldInterceptDomTerminalKey(args: { altKey: boolean; pendingModifiers: PendingTerminalModifiers; }): boolean { - return ( - args.key.length > 1 || - args.ctrlKey || - args.altKey || - hasPendingTerminalModifiers(args.pendingModifiers) - ); + return hasPendingTerminalModifiers(args.pendingModifiers); } export function mergeTerminalModifiers(args: { From 120c1b46a4c18e3d18bff3df38a5b4483e5d731a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 15:12:32 +0700 Subject: [PATCH 12/15] fix: remove terminal stream backpressure that caused 14MB snapshot thrashing The high water mark (256KB) was triggering snapshot mode during normal output bursts, replacing incremental output with full terminal state snapshots (~14MB). This made the terminal feel laggy even on localhost. Snapshots still fire on initial attach and tab switch where they're semantically needed. --- packages/server/src/server/session.ts | 37 --------------------------- 1 file changed, 37 deletions(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 17e617b4c..ac4ea8ac6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -231,8 +231,6 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; -const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; -const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024; const MAX_TERMINAL_STREAM_SLOTS = 256; function deriveInitialAgentTitle(prompt: string): string | null { @@ -296,7 +294,6 @@ type ActiveTerminalStream = { slot: number; unsubscribe: () => void; needsSnapshot: boolean; - snapshotRetryTimer: ReturnType | null; }; export type SessionRuntimeMetrics = { @@ -8424,7 +8421,6 @@ export class Session { slot, unsubscribe: () => {}, needsSnapshot: true, - snapshotRetryTimer: null, }; this.activeTerminalStreams.set(slot, activeStream); @@ -8441,10 +8437,6 @@ export class Session { if (activeStream.needsSnapshot || message.data.length === 0) { return; } - if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) { - this.markAllActiveTerminalStreamsForSnapshot(); - return; - } this.emitBinary( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, @@ -8452,9 +8444,6 @@ export class Session { payload: new Uint8Array(Buffer.from(message.data, "utf8")), }), ); - if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) { - this.markAllActiveTerminalStreamsForSnapshot(); - } }); return slot; } @@ -8467,21 +8456,6 @@ export class Session { return; } - if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_LOW_WATER_BYTES) { - if (!activeStream.snapshotRetryTimer) { - activeStream.snapshotRetryTimer = setTimeout(() => { - activeStream.snapshotRetryTimer = null; - this.trySendTerminalSnapshot(activeStream); - }, 33); - } - return; - } - - if (activeStream.snapshotRetryTimer) { - clearTimeout(activeStream.snapshotRetryTimer); - activeStream.snapshotRetryTimer = null; - } - const terminal = this.terminalManager?.getTerminal(activeStream.terminalId); if (!terminal) { this.detachTerminalStream(activeStream.terminalId, { emitExit: true }); @@ -8498,13 +8472,6 @@ export class Session { ); } - private markAllActiveTerminalStreamsForSnapshot(): void { - for (const activeStream of this.activeTerminalStreams.values()) { - activeStream.needsSnapshot = true; - this.trySendTerminalSnapshot(activeStream); - } - } - private allocateTerminalSlot(): number | null { for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) { const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS; @@ -8529,10 +8496,6 @@ export class Session { } this.activeTerminalStreams.delete(slot); this.terminalIdToSlot.delete(terminalId); - if (activeStream.snapshotRetryTimer) { - clearTimeout(activeStream.snapshotRetryTimer); - activeStream.snapshotRetryTimer = null; - } try { activeStream.unsubscribe(); } catch (error) { From 2f77674c5504f98ba51bfc087b4bc1ea86cd09d3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 16:21:05 +0700 Subject: [PATCH 13/15] feat: add theme selector with multiple dark themes Add theme dropdown in settings with Light, Dark, System plus Zinc, Midnight, Claude, and Ghostty dark variants. Each theme has its own accent color and surface tints. Desaturate terminal ANSI colors across all dark themes. Add surfaceSidebarHover semantic token so hover states go the right direction in both light and dark modes. Self-heal if a stored theme no longer exists. --- packages/app/src/app/_layout.tsx | 3 +- .../app/src/components/explorer-sidebar.tsx | 2 +- .../app/src/components/file-explorer-pane.tsx | 2 +- packages/app/src/components/left-sidebar.tsx | 6 +- .../src/components/sidebar-workspace-list.tsx | 10 +- packages/app/src/hooks/use-settings.ts | 9 +- packages/app/src/screens/settings-screen.tsx | 119 ++++-- packages/app/src/styles/theme.ts | 339 +++++++++++++----- packages/app/src/styles/unistyles.ts | 20 +- 9 files changed, 375 insertions(+), 135 deletions(-) diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 03483af99..e1b41b450 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -14,6 +14,7 @@ import { BottomSheetModalProvider } from "@gorhom/bottom-sheet"; import { PortalProvider } from "@gorhom/portal"; import { VoiceProvider } from "@/contexts/voice-context"; import { useAppSettings } from "@/hooks/use-settings"; +import { THEME_TO_UNISTYLES } from "@/styles/theme"; import { useFaviconStatus } from "@/hooks/use-favicon-status"; import { View, Text } from "react-native"; import { UnistylesRuntime, useUnistyles } from "react-native-unistyles"; @@ -561,7 +562,7 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { UnistylesRuntime.setAdaptiveThemes(true); } else { UnistylesRuntime.setAdaptiveThemes(false); - UnistylesRuntime.setTheme(settings.theme); + UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]); } }, [settingsLoading, settings.theme]); diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index fcfe9a140..f324ae158 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -472,7 +472,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.md, }, tabActive: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, tabText: { fontSize: theme.fontSize.sm, diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 983fd1147..b1d0230f9 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -849,7 +849,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.md, }, entryRowActive: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, indentGuide: { position: "absolute", diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 5d9b27e63..c3eb01e31 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -885,10 +885,10 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, newAgentButtonHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, newAgentButtonActive: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, hostTrigger: { flexDirection: "row", @@ -901,7 +901,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.lg, }, hostTriggerHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, hostStatusDot: { width: 8, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 5ae598445..e6fb347b7 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1980,7 +1980,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, projectRowHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, projectRowPressed: { backgroundColor: theme.colors.surface2, @@ -2050,7 +2050,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, projectActionButtonHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, projectActionButtonText: { color: theme.colors.foregroundMuted, @@ -2065,7 +2065,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, projectIconActionButtonHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, projectIconActionButtonHidden: { opacity: 0, @@ -2143,7 +2143,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, workspaceRowHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, workspaceRowPressed: { backgroundColor: theme.colors.surface2, @@ -2157,7 +2157,7 @@ const styles = StyleSheet.create((theme) => ({ ...theme.shadow.md, }, sidebarRowSelected: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, }, workspaceRowContainer: { position: "relative", diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index 762f610e5..1c3793e49 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -6,10 +6,14 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; const APP_SETTINGS_QUERY_KEY = ["app-settings"]; +import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; + export type SendBehavior = "interrupt" | "queue"; +const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); + export interface AppSettings { - theme: "dark" | "light" | "auto"; + theme: ThemeName | "auto"; manageBuiltInDaemon: boolean; sendBehavior: SendBehavior; } @@ -78,6 +82,9 @@ export async function loadSettingsFromStorage(): Promise { const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY); if (stored) { const parsed = JSON.parse(stored) as Partial; + if (parsed.theme && !VALID_THEMES.has(parsed.theme)) { + parsed.theme = DEFAULT_APP_SETTINGS.theme; + } return { ...DEFAULT_APP_SETTINGS, ...parsed }; } diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 507a5a220..f25aa969b 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -10,6 +10,7 @@ import { Sun, Moon, Monitor, + ChevronDown, Globe, Settings, RotateCw, @@ -24,6 +25,7 @@ import { Smartphone, } from "lucide-react-native"; import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings"; +import { THEME_SWATCHES, type ThemeName } from "@/styles/theme"; import type { HostProfile, HostConnection } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; @@ -47,6 +49,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; @@ -362,11 +365,53 @@ interface GeneralSectionProps { handleSendBehaviorChange: (behavior: SendBehavior) => void; } +function ThemeIcon({ theme, size, color }: { theme: AppSettings["theme"]; size: number; color: string }) { + switch (theme) { + case "light": + return ; + case "dark": + return ; + case "auto": + return ; + default: + return ; + } +} + +function ThemeSwatch({ color, size }: { color: string; size: number }) { + return ( + + ); +} + +const THEME_LABELS: Record = { + light: "Light", + dark: "Dark", + zinc: "Zinc", + midnight: "Midnight", + claude: "Claude", + ghostty: "Ghostty", + auto: "System", +}; + function GeneralSection({ settings, handleThemeChange, handleSendBehaviorChange, }: GeneralSectionProps) { + const { theme } = useUnistyles(); + const iconSize = theme.iconSize.md; + const iconColor = theme.colors.foregroundMuted; + return ( General @@ -375,29 +420,43 @@ function GeneralSection({ Theme - , - }, - { - value: "dark", - label: "Dark", - icon: ({ color, size }) => , - }, - { - value: "auto", - label: "System", - icon: ({ color, size }) => , - }, - ]} - /> + + [ + styles.themeTrigger, + pressed && { opacity: 0.85 }, + ]} + > + + + {THEME_LABELS[settings.theme]} + + + + + {(["light", "dark", "auto"] as const).map((t) => ( + handleThemeChange(t)} + leading={} + > + {THEME_LABELS[t]} + + ))} + + {(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => ( + handleThemeChange(t)} + leading={} + > + {THEME_LABELS[t]} + + ))} + + @@ -1806,6 +1865,20 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, + themeTrigger: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + }, + themeTriggerText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, disabled: { opacity: theme.opacity[50], }, diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 1c72d8b59..c0f40f86b 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -103,6 +103,8 @@ export const baseColors = { }, } as const; +export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty"; + // Semantic color tokens - Layer-based system const lightSemanticColors = { // Surfaces (layers) - shifted one step lighter @@ -113,10 +115,11 @@ const lightSemanticColors = { surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300) surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2 surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main) + surfaceSidebarHover: "#e9e9ec", // Sidebar hover (darker in light mode) surfaceWorkspace: "#ffffff", // Workspace main background // Text - foreground: "#09090b", + foreground: "#1a1a1e", foregroundMuted: "#71717a", // Controls @@ -140,11 +143,11 @@ const lightSemanticColors = { // Legacy aliases (for gradual migration) background: "#ffffff", popover: "#ffffff", - popoverForeground: "#09090b", + popoverForeground: "#1a1a1e", primary: "#18181b", primaryForeground: "#fafafa", secondary: "#f4f4f5", - secondaryForeground: "#09090b", + secondaryForeground: "#1a1a1e", muted: "#f4f4f5", mutedForeground: "#71717a", accentBorder: "#ececf1", @@ -153,13 +156,13 @@ const lightSemanticColors = { terminal: { background: "#ffffff", - foreground: "#09090b", - cursor: "#09090b", + foreground: "#1a1a1e", + cursor: "#1a1a1e", cursorAccent: "#ffffff", selectionBackground: "rgba(0, 0, 0, 0.15)", - selectionForeground: "#09090b", + selectionForeground: "#1a1a1e", - black: "#09090b", + black: "#1a1a1e", red: "#dc2626", green: "#16a34a", yellow: "#ca8a04", @@ -179,80 +182,194 @@ const lightSemanticColors = { }, } as const; -const darkSemanticColors = { - // Surfaces (layers) — subtle teal tint - surface0: "#181B1A", // App background - surface1: "#1E2120", // Subtle hover - surface2: "#272A29", // Elevated: badges, inputs, sheets - surface3: "#434645", // Highest elevation - surface4: "#595B5B", // Extra emphasis - surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2 - surfaceSidebar: "#141716", // Sidebar background (darker than main) - surfaceWorkspace: "#1E2120", // Workspace main background (surface1) +// --------------------------------------------------------------------------- +// Dark theme variant builder +// --------------------------------------------------------------------------- - // Text - foreground: "#fafafa", +interface DarkThemeConfig { + surface0: string; + surface1: string; + surface2: string; + surface3: string; + surface4: string; + surfaceDiffEmpty: string; + surfaceSidebar: string; + surfaceSidebarHover: string; + foregroundMuted: string; + scrollbarHandle: string; + border: string; + borderAccent: string; + accent: string; + accentBright: string; +} + +const darkTerminalAnsi = { + red: "#e07070", + green: "#5dba80", + yellow: "#d4a44a", + blue: "#6a9de0", + magenta: "#b07ad0", + cyan: "#4aabb8", + white: "#d4d4d8", + brightRed: "#e89090", + brightGreen: "#7ecf9a", + brightYellow: "#e0be6e", + brightBlue: "#8ab4e8", + brightMagenta: "#c49ae0", + brightCyan: "#6ec2cc", + brightWhite: "#f0f0f2", +} as const; + +function buildDarkSemanticColors(tint: DarkThemeConfig) { + return { + surface0: tint.surface0, + surface1: tint.surface1, + surface2: tint.surface2, + surface3: tint.surface3, + surface4: tint.surface4, + surfaceDiffEmpty: tint.surfaceDiffEmpty, + surfaceSidebar: tint.surfaceSidebar, + surfaceSidebarHover: tint.surfaceSidebarHover, + surfaceWorkspace: tint.surface1, + + foreground: "#fafafa", + foregroundMuted: tint.foregroundMuted, + + scrollbarHandle: tint.scrollbarHandle, + + border: tint.border, + borderAccent: tint.borderAccent, + + accent: tint.accent, + accentBright: tint.accentBright, + accentForeground: "#ffffff", + + destructive: "#ef4444", + destructiveForeground: "#ffffff", + success: tint.accent, + successForeground: "#ffffff", + + // Legacy aliases (for gradual migration) + background: tint.surface0, + popover: tint.surface2, + popoverForeground: "#fafafa", + primary: "#fafafa", + primaryForeground: tint.surface0, + secondary: tint.surface2, + secondaryForeground: "#fafafa", + muted: tint.surface2, + mutedForeground: tint.foregroundMuted, + accentBorder: tint.borderAccent, + input: tint.surface2, + ring: "#d4d4d8", + + terminal: { + background: tint.surface0, + foreground: "#fafafa", + cursor: "#fafafa", + cursorAccent: tint.surface0, + selectionBackground: "rgba(255, 255, 255, 0.2)", + selectionForeground: "#fafafa", + black: tint.surfaceSidebar, + ...darkTerminalAnsi, + brightBlack: tint.surface3, + }, + }; +} + +// --------------------------------------------------------------------------- +// Dark tint definitions +// --------------------------------------------------------------------------- + +// Paseo — subtle teal-green tint (default) +const paseoDarkColors = buildDarkSemanticColors({ + surface0: "#181B1A", + surface1: "#1E2120", + surface2: "#272A29", + surface3: "#434645", + surface4: "#595B5B", + surfaceDiffEmpty: "#252827", + surfaceSidebar: "#141716", + surfaceSidebarHover: "#1c1f1e", foregroundMuted: "#A1A5A4", - - // Controls - scrollbarHandle: "#717574", // zinc-500 w/ teal tint - - // Borders + scrollbarHandle: "#717574", border: "#252B2A", borderAccent: "#2F3534", - - // Brand accent: "#20744A", accentBright: "#7ccba0", - accentForeground: "#ffffff", +}); - // Semantic - destructive: "#ef4444", - destructiveForeground: "#ffffff", - success: "#20744A", - successForeground: "#ffffff", +// Zinc — neutral gray, no tint +const zincDarkColors = buildDarkSemanticColors({ + surface0: "#18181b", + surface1: "#1f1f22", + surface2: "#27272a", + surface3: "#3f3f46", + surface4: "#52525b", + surfaceDiffEmpty: "#242427", + surfaceSidebar: "#131316", + surfaceSidebarHover: "#1b1b1e", + foregroundMuted: "#a1a1aa", + scrollbarHandle: "#71717a", + border: "#27272a", + borderAccent: "#303036", + accent: "#20744A", + accentBright: "#7ccba0", +}); - // Legacy aliases (for gradual migration) - background: "#181B1A", - popover: "#272A29", - popoverForeground: "#fafafa", - primary: "#fafafa", - primaryForeground: "#181B1A", - secondary: "#272A29", - secondaryForeground: "#fafafa", - muted: "#272A29", - mutedForeground: "#A1A5A4", - accentBorder: "#2F3534", - input: "#272A29", - ring: "#d4d4d8", +// Midnight — subtle blue tint +const midnightDarkColors = buildDarkSemanticColors({ + surface0: "#161820", + surface1: "#1c1e27", + surface2: "#252731", + surface3: "#3c3e4c", + surface4: "#535564", + surfaceDiffEmpty: "#222430", + surfaceSidebar: "#121420", + surfaceSidebarHover: "#1a1c28", + foregroundMuted: "#9a9db0", + scrollbarHandle: "#6b6e82", + border: "#242636", + borderAccent: "#2e3040", + accent: "#3b6fcf", + accentBright: "#7eaaeb", +}); - terminal: { - background: "#181B1A", - foreground: "#fafafa", - cursor: "#fafafa", - cursorAccent: "#181B1A", - selectionBackground: "rgba(255, 255, 255, 0.2)", - selectionForeground: "#fafafa", +// Claude — warm neutral with subtle orange undertone +const claudeDarkColors = buildDarkSemanticColors({ + surface0: "#1f1f1e", + surface1: "#262523", + surface2: "#2f2d2b", + surface3: "#4a4745", + surface4: "#605d5b", + surfaceDiffEmpty: "#2a2826", + surfaceSidebar: "#1a1918", + surfaceSidebarHover: "#222120", + foregroundMuted: "#ada9a5", + scrollbarHandle: "#78746f", + border: "#2c2a27", + borderAccent: "#36332f", + accent: "#d97757", + accentBright: "#e89a7f", +}); - black: "#141716", - red: "#ef4444", - green: "#22c55e", - yellow: "#f59e0b", - blue: "#3b82f6", - magenta: "#a855f7", - cyan: "#06b6d4", - white: "#e4e4e7", - - brightBlack: "#434645", - brightRed: "#f87171", - brightGreen: "#4ade80", - brightYellow: "#fbbf24", - brightBlue: "#60a5fa", - brightMagenta: "#c084fc", - brightCyan: "#22d3ee", - brightWhite: "#ffffff", - }, -} as const; +// Ghostty — blue-tinted dark based on Ghostty default background +const ghosttyDarkColors = buildDarkSemanticColors({ + surface0: "#282c34", + surface1: "#2f333d", + surface2: "#383c48", + surface3: "#4a4f5e", + surface4: "#5b6175", + surfaceDiffEmpty: "#323643", + surfaceSidebar: "#21252d", + surfaceSidebarHover: "#292d36", + foregroundMuted: "#c8ccd8", + scrollbarHandle: "#a0a4b2", + border: "#353a47", + borderAccent: "#3f4454", + accent: "#89b4fa", + accentBright: "#b4d0fc", +}); const commonTheme = { spacing: { @@ -323,35 +440,45 @@ const commonTheme = { }, } as const; -export const darkTheme = { - colorScheme: "dark" as const, - colors: { - ...darkSemanticColors, - palette: baseColors, +const darkShadow = { + sm: { + shadowColor: "rgba(0, 0, 0, 0.25)", + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 4, + elevation: 2, }, - shadow: { - sm: { - shadowColor: "rgba(0, 0, 0, 0.25)", - shadowOffset: { width: 0, height: 2 }, - shadowRadius: 4, - elevation: 2, - }, - md: { - shadowColor: "rgba(0, 0, 0, 0.20)", - shadowOffset: { width: 0, height: 4 }, - shadowRadius: 8, - elevation: 8, - }, - lg: { - shadowColor: "rgba(0, 0, 0, 0.40)", - shadowOffset: { width: 0, height: 12 }, - shadowRadius: 24, - elevation: 8, - }, + md: { + shadowColor: "rgba(0, 0, 0, 0.20)", + shadowOffset: { width: 0, height: 4 }, + shadowRadius: 8, + elevation: 8, + }, + lg: { + shadowColor: "rgba(0, 0, 0, 0.40)", + shadowOffset: { width: 0, height: 12 }, + shadowRadius: 24, + elevation: 8, }, - ...commonTheme, } as const; +function buildDarkTheme(semanticColors: ReturnType) { + return { + colorScheme: "dark" as const, + colors: { + ...semanticColors, + palette: baseColors, + }, + shadow: darkShadow, + ...commonTheme, + } as const; +} + +export const darkTheme = buildDarkTheme(paseoDarkColors); +export const darkZincTheme = buildDarkTheme(zincDarkColors); +export const darkMidnightTheme = buildDarkTheme(midnightDarkColors); +export const darkClaudeTheme = buildDarkTheme(claudeDarkColors); +export const darkGhosttyTheme = buildDarkTheme(ghosttyDarkColors); + export const lightTheme = { colorScheme: "light" as const, colors: { @@ -386,3 +513,23 @@ export const theme = darkTheme; // Export a union type that works for both themes export type Theme = typeof darkTheme | typeof lightTheme; + +type UnistylesThemeKey = "light" | "dark" | "darkZinc" | "darkMidnight" | "darkClaude" | "darkGhostty"; + +export const THEME_TO_UNISTYLES: Record = { + light: "light", + dark: "dark", + zinc: "darkZinc", + midnight: "darkMidnight", + claude: "darkClaude", + ghostty: "darkGhostty", +}; + +export const THEME_SWATCHES: Record = { + light: "#ffffff", + dark: "#2D8B62", + zinc: "#808080", + midnight: "#4A6BA8", + claude: "#D97757", + ghostty: "#8caaee", +}; diff --git a/packages/app/src/styles/unistyles.ts b/packages/app/src/styles/unistyles.ts index 4bcb6e072..3bb09dc8a 100644 --- a/packages/app/src/styles/unistyles.ts +++ b/packages/app/src/styles/unistyles.ts @@ -1,11 +1,21 @@ import { StyleSheet } from "react-native-unistyles"; -// import { UnistylesRuntime } from "react-native-unistyles"; -import { lightTheme, darkTheme } from "./theme"; +import { + lightTheme, + darkTheme, + darkZincTheme, + darkMidnightTheme, + darkClaudeTheme, + darkGhosttyTheme, +} from "./theme"; StyleSheet.configure({ themes: { light: lightTheme, dark: darkTheme, + darkZinc: darkZincTheme, + darkMidnight: darkMidnightTheme, + darkClaude: darkClaudeTheme, + darkGhostty: darkGhosttyTheme, }, breakpoints: { xs: 0, @@ -23,6 +33,10 @@ StyleSheet.configure({ type AppThemes = { light: typeof lightTheme; dark: typeof darkTheme; + darkZinc: typeof darkZincTheme; + darkMidnight: typeof darkMidnightTheme; + darkClaude: typeof darkClaudeTheme; + darkGhostty: typeof darkGhosttyTheme; }; type AppBreakpoints = { @@ -37,5 +51,3 @@ declare module "react-native-unistyles" { export interface UnistylesThemes extends AppThemes {} export interface UnistylesBreakpoints extends AppBreakpoints {} } - -// UnistylesRuntime.setRootViewBackgroundColor(lightTheme.colors.background); From b9a8ba054d04fdf0d5a1f228108a64c14fa4c3ef Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 16:55:04 +0700 Subject: [PATCH 14/15] fix(app): keep diff rows aligned for blank lines --- packages/app/src/components/git-diff-pane.tsx | 71 +++++++++--------- packages/app/src/utils/diff-layout.test.ts | 75 ++++++++++++++++++- packages/app/src/utils/diff-layout.ts | 39 ++++++++++ packages/app/src/utils/diff-rendering.test.ts | 23 ++++++ packages/app/src/utils/diff-rendering.ts | 16 ++++ 5 files changed, 187 insertions(+), 37 deletions(-) create mode 100644 packages/app/src/utils/diff-rendering.test.ts create mode 100644 packages/app/src/utils/diff-rendering.ts diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 41e3dcf23..2d0c3d766 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -57,7 +57,12 @@ import { import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; -import { buildSplitDiffRows, type SplitDiffDisplayLine, type SplitDiffRow } from "@/utils/diff-layout"; +import { + buildSplitDiffRows, + buildUnifiedDiffLines, + type SplitDiffDisplayLine, + type SplitDiffRow, +} from "@/utils/diff-layout"; import { DropdownMenu, DropdownMenuContent, @@ -75,6 +80,11 @@ import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; +import { + formatDiffContentText, + formatDiffGutterText, + hasVisibleDiffTokens, +} from "@/utils/diff-rendering"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; @@ -165,7 +175,7 @@ function DiffGutterCell({ type === "remove" && styles.removeLineNumberText, ]} > - {lineNumber != null ? String(lineNumber) : ""} + {formatDiffGutterText(lineNumber)} ); @@ -178,10 +188,12 @@ function DiffTextLine({ line: DiffLine; wrapLines: boolean; }) { + const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + return ( - {line.tokens && line.type !== "header" ? ( - + {line.type !== "header" && visibleTokens ? ( + ) : ( - {line.content || " "} + {formatDiffContentText(line.content)} )} @@ -207,10 +219,12 @@ function SplitTextLine({ line: SplitDiffDisplayLine | null; wrapLines: boolean; }) { + const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + return ( - {line?.tokens ? ( - + {visibleTokens ? ( + ) : ( - {line?.content ?? ""} + {formatDiffContentText(line?.content)} )} @@ -240,6 +254,8 @@ function DiffLineView({ gutterWidth: number; wrapLines: boolean; }) { + const visibleTokens = hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + return ( - {lineNumber != null ? String(lineNumber) : ""} + {formatDiffGutterText(lineNumber)} - {line.tokens && line.type !== "header" ? ( - + {line.type !== "header" && visibleTokens ? ( + ) : ( - {line.content || " "} + {formatDiffContentText(line.content)} )} @@ -287,6 +303,8 @@ function SplitDiffLine({ gutterWidth: number; wrapLines: boolean; }) { + const visibleTokens = line && hasVisibleDiffTokens(line.tokens) ? line.tokens : null; + return ( - {line?.lineNumber != null ? String(line.lineNumber) : ""} + {formatDiffGutterText(line?.lineNumber ?? null)} - {line?.tokens ? ( - + {visibleTokens ? ( + ) : ( - {line?.content ?? ""} + {formatDiffContentText(line?.content)} )} @@ -535,26 +553,7 @@ function DiffFileBody({ ); } - const computedLines: { line: DiffLine; lineNumber: number | null; key: string }[] = []; - for (const [hunkIndex, hunk] of file.hunks.entries()) { - let oldLineNo = hunk.oldStart; - let newLineNo = hunk.newStart; - for (const [lineIndex, line] of hunk.lines.entries()) { - let lineNumber: number | null = null; - if (line.type === "remove") { - lineNumber = oldLineNo; - oldLineNo++; - } else if (line.type === "add") { - lineNumber = newLineNo; - newLineNo++; - } else if (line.type === "context") { - lineNumber = newLineNo; - oldLineNo++; - newLineNo++; - } - computedLines.push({ line, lineNumber, key: `${hunkIndex}-${lineIndex}` }); - } - } + const computedLines = buildUnifiedDiffLines(file); if (wrapLines) { return ( diff --git a/packages/app/src/utils/diff-layout.test.ts b/packages/app/src/utils/diff-layout.test.ts index c5e756d81..a6f00f0cc 100644 --- a/packages/app/src/utils/diff-layout.test.ts +++ b/packages/app/src/utils/diff-layout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildSplitDiffRows } from "./diff-layout"; +import { buildSplitDiffRows, buildUnifiedDiffLines } from "./diff-layout"; import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query"; function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile { @@ -79,3 +79,76 @@ describe("buildSplitDiffRows", () => { }); }); }); + +describe("buildUnifiedDiffLines", () => { + it("computes line numbers per line type within a hunk", () => { + const lines = buildUnifiedDiffLines( + makeFile([ + { type: "header", content: "@@ -10,3 +10,4 @@" }, + { type: "context", content: "before" }, + { type: "add", content: "inserted" }, + { type: "remove", content: "removed" }, + { type: "context", content: "after" }, + ]), + ); + + expect( + lines.map(({ line, lineNumber }) => ({ + type: line.type, + lineNumber, + content: line.content, + })), + ).toEqual([ + { type: "header", lineNumber: null, content: "@@ -10,3 +10,4 @@" }, + { type: "context", lineNumber: 10, content: "before" }, + { type: "add", lineNumber: 11, content: "inserted" }, + { type: "remove", lineNumber: 11, content: "removed" }, + { type: "context", lineNumber: 12, content: "after" }, + ]); + }); + + it("restarts numbering at each hunk boundary", () => { + const file: ParsedDiffFile = { + path: "example.ts", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 0, + status: "ok", + hunks: [ + { + oldStart: 75, + oldCount: 2, + newStart: 75, + newCount: 3, + lines: [ + { type: "header", content: "@@ -75,2 +75,3 @@" }, + { type: "context", content: "first" }, + { type: "add", content: "inserted" }, + { type: "context", content: "second" }, + ], + }, + { + oldStart: 165, + oldCount: 2, + newStart: 166, + newCount: 2, + lines: [ + { type: "header", content: "@@ -165,2 +166,2 @@" }, + { type: "context", content: "third" }, + { type: "context", content: "fourth" }, + ], + }, + ], + }; + + const lines = buildUnifiedDiffLines(file); + + expect(lines[0]?.lineNumber).toBeNull(); + expect(lines[1]?.lineNumber).toBe(75); + expect(lines[3]?.lineNumber).toBe(77); + expect(lines[4]?.lineNumber).toBeNull(); + expect(lines[5]?.lineNumber).toBe(166); + expect(lines[6]?.lineNumber).toBe(167); + }); +}); diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts index b5746eab3..e6d9a2473 100644 --- a/packages/app/src/utils/diff-layout.ts +++ b/packages/app/src/utils/diff-layout.ts @@ -7,6 +7,12 @@ export interface SplitDiffDisplayLine { lineNumber: number | null; } +export interface UnifiedDiffDisplayLine { + key: string; + line: DiffLine; + lineNumber: number | null; +} + export type SplitDiffRow = | { kind: "header"; @@ -61,6 +67,39 @@ function toDisplayLine(input: { }; } +export function buildUnifiedDiffLines(file: ParsedDiffFile): UnifiedDiffDisplayLine[] { + const lines: UnifiedDiffDisplayLine[] = []; + + for (const [hunkIndex, hunk] of file.hunks.entries()) { + let oldLineNo = hunk.oldStart; + let newLineNo = hunk.newStart; + + for (const [lineIndex, line] of hunk.lines.entries()) { + let lineNumber: number | null = null; + + if (line.type === "remove") { + lineNumber = oldLineNo; + oldLineNo += 1; + } else if (line.type === "add") { + lineNumber = newLineNo; + newLineNo += 1; + } else if (line.type === "context") { + lineNumber = newLineNo; + oldLineNo += 1; + newLineNo += 1; + } + + lines.push({ + key: `${hunkIndex}-${lineIndex}`, + line, + lineNumber, + }); + } + } + + return lines; +} + export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] { const rows: SplitDiffRow[] = []; diff --git a/packages/app/src/utils/diff-rendering.test.ts b/packages/app/src/utils/diff-rendering.test.ts new file mode 100644 index 000000000..c63f18e5f --- /dev/null +++ b/packages/app/src/utils/diff-rendering.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens } from "./diff-rendering"; + +describe("diff-rendering", () => { + it("keeps header gutters tall even when they do not show a line number", () => { + expect(formatDiffGutterText(null)).toBe(" "); + expect(formatDiffGutterText(82)).toBe("82"); + }); + + it("keeps empty split cells tall even when they have no visible content", () => { + expect(formatDiffContentText(undefined)).toBe(" "); + expect(formatDiffContentText("")).toBe(" "); + expect(formatDiffContentText("const value = 1;")).toBe("const value = 1;"); + }); + + it("treats empty highlighted token rows as blank lines instead of visible content", () => { + expect(hasVisibleDiffTokens(undefined)).toBe(false); + expect(hasVisibleDiffTokens([])).toBe(false); + expect(hasVisibleDiffTokens([{ text: "" }])).toBe(false); + expect(hasVisibleDiffTokens([{ text: "const value = 1;" }])).toBe(true); + }); +}); diff --git a/packages/app/src/utils/diff-rendering.ts b/packages/app/src/utils/diff-rendering.ts new file mode 100644 index 000000000..ec18716c7 --- /dev/null +++ b/packages/app/src/utils/diff-rendering.ts @@ -0,0 +1,16 @@ +interface HighlightLikeToken { + text: string; +} + +// Preserve row height when a gutter or diff cell is intentionally blank. +export function formatDiffGutterText(lineNumber: number | null): string { + return lineNumber == null ? " " : String(lineNumber); +} + +export function formatDiffContentText(content: string | null | undefined): string { + return content && content.length > 0 ? content : " "; +} + +export function hasVisibleDiffTokens(tokens: HighlightLikeToken[] | null | undefined): boolean { + return Boolean(tokens?.some((token) => token.text.length > 0)); +} From 49e67363b2bb335e1892d11b731218f0eb3fa402 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 10 Apr 2026 17:06:41 +0700 Subject: [PATCH 15/15] feat(app): add themeable diff stat colors and soften tooltip border Add diffAddition/diffDeletion semantic tokens with separate light (muted green-700/red-700) and dark (bright green-400/red-500) sets, spread into themes so individual variants can override. Update all diff stat consumers to use the new tokens. Soften tooltip border to match dropdown menu styling (thinner border, accent color, medium shadow). --- packages/app/src/components/git-diff-pane.tsx | 12 ++++++------ .../app/src/components/sidebar-workspace-list.tsx | 4 ++-- packages/app/src/components/ui/tooltip.tsx | 6 +++--- .../src/screens/workspace/workspace-screen.tsx | 4 ++-- packages/app/src/styles/theme.ts | 15 +++++++++++++++ 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 2d0c3d766..70e7135ba 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1724,7 +1724,7 @@ const styles = StyleSheet.create((theme) => ({ newBadgeText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.green[400], + color: theme.colors.diffAddition, }, deletedBadge: { backgroundColor: "rgba(248, 81, 73, 0.2)", @@ -1736,17 +1736,17 @@ const styles = StyleSheet.create((theme) => ({ deletedBadgeText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.red[500], + color: theme.colors.diffDeletion, }, additions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.green[400], + color: theme.colors.diffAddition, }, deletions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.red[500], + color: theme.colors.diffDeletion, }, diffContent: { borderTopWidth: theme.borderWidth[1], @@ -1824,10 +1824,10 @@ const styles = StyleSheet.create((theme) => ({ userSelect: "none", }, addLineNumberText: { - color: theme.colors.palette.green[400], + color: theme.colors.diffAddition, }, removeLineNumberText: { - color: theme.colors.palette.red[500], + color: theme.colors.diffDeletion, }, diffLineText: { flex: 1, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index e6fb347b7..94fca278e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -2241,12 +2241,12 @@ const styles = StyleSheet.create((theme) => ({ diffStatAdditions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.green[400], + color: theme.colors.diffAddition, }, diffStatDeletions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.red[500], + color: theme.colors.diffDeletion, }, kebabButton: { padding: 2, diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index fdb9eb4a3..5409a1a98 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -551,9 +551,9 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.xl, backgroundColor: theme.colors.popover, - borderWidth: theme.borderWidth[2], - borderColor: theme.colors.border, - ...theme.shadow.sm, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + ...theme.shadow.md, zIndex: 1000, }, })); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a956b9dfd..2bbf82ff6 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -2371,12 +2371,12 @@ const styles = StyleSheet.create((theme) => ({ diffStatAdditions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.green[400], + color: theme.colors.diffAddition, }, diffStatDeletions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, - color: theme.colors.palette.red[500], + color: theme.colors.diffDeletion, }, newTabActions: { flexDirection: "row", diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index c0f40f86b..cc9380ab9 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -105,6 +105,17 @@ export const baseColors = { export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty"; +// Diff stat colors — light uses muted tones, dark uses the brighter palette values +const lightDiffColors = { + diffAddition: "#15803d", // green-700 — readable on white without screaming + diffDeletion: "#b91c1c", // red-700 +}; + +const darkDiffColors = { + diffAddition: "#4ade80", // green-400 + diffDeletion: "#ef4444", // red-500 +}; + // Semantic color tokens - Layer-based system const lightSemanticColors = { // Surfaces (layers) - shifted one step lighter @@ -154,6 +165,8 @@ const lightSemanticColors = { input: "#f4f4f5", ring: "#18181b", + ...lightDiffColors, + terminal: { background: "#ffffff", foreground: "#1a1a1e", @@ -263,6 +276,8 @@ function buildDarkSemanticColors(tint: DarkThemeConfig) { input: tint.surface2, ring: "#d4d4d8", + ...darkDiffColors, + terminal: { background: tint.surface0, foreground: "#fafafa",