Refactor git actions and update React to 19.1.4

This commit is contained in:
Mohamed Boudra
2026-03-10 15:49:33 +07:00
parent 2b17aa1a1d
commit 845cf68d38
25 changed files with 1660 additions and 620 deletions

138
package-lock.json generated
View File

@@ -7731,6 +7731,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7744,6 +7745,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7757,6 +7759,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7770,6 +7773,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7783,6 +7787,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7796,6 +7801,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7809,6 +7815,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7822,6 +7829,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7835,6 +7843,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7848,6 +7857,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7861,6 +7871,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7874,6 +7885,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7887,6 +7899,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7900,6 +7913,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7913,6 +7927,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7926,6 +7941,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7939,6 +7955,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7952,6 +7969,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7965,6 +7983,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7978,6 +7997,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -7991,6 +8011,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8004,6 +8025,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8017,6 +8039,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8030,6 +8053,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -8043,6 +8067,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -9486,6 +9511,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -9495,7 +9521,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -12509,6 +12535,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/data-view-buffer": {
@@ -17213,7 +17240,7 @@
"version": "16.8.1",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.8.1.tgz",
"integrity": "sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
@@ -18627,7 +18654,7 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -22771,15 +22798,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-devtools-core": {
"version": "6.1.5",
"resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz",
@@ -22811,18 +22829,6 @@
}
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
@@ -23051,17 +23057,6 @@
"react-native": ">=0.50.4"
}
},
"node_modules/react-native-nitro-modules": {
"version": "0.30.2",
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.30.2.tgz",
"integrity": "sha512-+/uVS7FQwOiKYZQERMIvBRv5/X3CVHrFG6Nr/kIhVfVxGeUimHnBz7cgA97lJKIn7AKDRWL+UjLedW8pGOt0dg==",
"hasInstallScript": true,
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-permissions": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.5.1.tgz",
@@ -23372,6 +23367,7 @@
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -23938,6 +23934,7 @@
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -26625,6 +26622,7 @@
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
@@ -26742,6 +26740,7 @@
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -27525,6 +27524,7 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -27654,8 +27654,8 @@
"lezer-elixir": "^1.1.2",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.4",
"react-dom": "19.1.4",
"react-native": "^0.81.5",
"react-native-css": "^3.0.1",
"react-native-draggable-flatlist": "^4.0.3",
@@ -27663,7 +27663,7 @@
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "^0.30.0",
"react-native-nitro-modules": "0.33.8",
"react-native-permissions": "^5.4.2",
"react-native-popover-view": "^6.1.0",
"react-native-reanimated": "~4.1.1",
@@ -27699,6 +27699,37 @@
"react-native": "*"
}
},
"packages/app/node_modules/react": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz",
"integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"packages/app/node_modules/react-dom": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
"integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.4"
}
},
"packages/app/node_modules/react-native-nitro-modules": {
"version": "0.33.8",
"resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.33.8.tgz",
"integrity": "sha512-1BffckbrPpxg6Een1Dj5EvijC9ctxEiEHvCBRiJWEnKKa5c24/JDgjfJFm1GyuaGtH5jlGVUbPzCcbIwiwEtuw==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"packages/app/node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
@@ -28140,8 +28171,8 @@
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
"wrangler": "^4.59.1"
},
@@ -28164,6 +28195,33 @@
"dependencies": {
"undici-types": "~6.21.0"
}
},
"packages/website/node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"packages/website/node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"packages/website/node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
}
}
}

View File

@@ -73,7 +73,9 @@
"author": "moboudra",
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1"
"lightningcss": "1.30.1",
"react": "19.1.4",
"react-dom": "19.1.4"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11"

View File

@@ -4,6 +4,7 @@ const fs = require("fs");
const path = require("path");
const projectRoot = __dirname;
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
@@ -14,6 +15,14 @@ const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
const config = getDefaultConfig(projectRoot);
const defaultResolveRequest = config.resolver.resolveRequest ?? resolve;
config.resolver.extraNodeModules = {
...(config.resolver.extraNodeModules ?? {}),
react: path.join(appNodeModulesRoot, "react"),
"react-dom": path.join(appNodeModulesRoot, "react-dom"),
"react/jsx-runtime": path.join(appNodeModulesRoot, "react/jsx-runtime"),
"react/jsx-dev-runtime": path.join(appNodeModulesRoot, "react/jsx-dev-runtime"),
};
function isLocalModuleImport(moduleName) {
return (
moduleName.startsWith("./") ||

View File

@@ -86,8 +86,8 @@
"lezer-elixir": "^1.1.2",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.4",
"react-dom": "19.1.4",
"react-native": "^0.81.5",
"react-native-css": "^3.0.1",
"react-native-draggable-flatlist": "^4.0.3",
@@ -95,7 +95,7 @@
"react-native-gesture-handler": "~2.28.0",
"react-native-keyboard-controller": "^1.19.2",
"react-native-markdown-display": "^7.0.2",
"react-native-nitro-modules": "^0.30.0",
"react-native-nitro-modules": "0.33.8",
"react-native-permissions": "^5.4.2",
"react-native-popover-view": "^6.1.0",
"react-native-reanimated": "~4.1.1",

View File

@@ -1,18 +1,16 @@
import { useEffect, useMemo } from "react";
import { ActivityIndicator, View } from "react-native";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostRootRoute } from "@/utils/host-routes";
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { WelcomeScreen } from "@/components/welcome-screen";
export default function Index() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const { theme } = useUnistyles();
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
const { daemons, isLoading: registryLoading, isReconciling } = useDaemonRegistry();
const { preferences, isLoading: preferencesLoading } = useFormPreferences();
const requestedServerId = useMemo(() => {
return typeof params.serverId === "string" ? params.serverId.trim() : "";
@@ -53,22 +51,14 @@ export default function Index() {
}, [pathname, preferencesLoading, registryLoading, router, targetServerId]);
if (registryLoading || preferencesLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
}}
>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
</View>
);
return <StartupSplashScreen />;
}
if (!targetServerId) {
return <DraftAgentScreen />;
if (isReconciling) {
return <StartupSplashScreen />;
}
return <WelcomeScreen />;
}
return null;

View File

@@ -412,6 +412,7 @@ function SidebarContent({
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
hideHeaderRow={!isMobile}
/>
)}
{resolvedTab === "files" && (

View File

@@ -0,0 +1,192 @@
import { useCallback } from "react";
import { View, Text, ActivityIndicator, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, MoreVertical } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { GitAction, GitActions } from "@/hooks/use-git-actions";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
}
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const getActionDisplayLabel = useCallback((action: GitAction): string => {
if (action.status === "pending") return action.pendingLabel;
if (action.status === "success") return action.successLabel;
return action.label;
}, []);
return (
<View style={styles.row}>
{gitActions.primary ? (
<View style={styles.splitButton}>
<Pressable
testID="changes-primary-cta"
style={({ hovered, pressed }) => [
styles.splitButtonPrimary,
(hovered || pressed) && styles.splitButtonPrimaryHovered,
gitActions.primary!.disabled && styles.splitButtonPrimaryDisabled,
]}
onPress={gitActions.primary.handler}
disabled={gitActions.primary.disabled}
accessibilityRole="button"
accessibilityLabel={gitActions.primary.label}
>
{gitActions.primary.status === "pending" ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
style={styles.splitButtonSpinnerOnly}
/>
) : (
<View style={styles.splitButtonContent}>
{gitActions.primary.icon}
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
</View>
)}
</Pressable>
{gitActions.secondary.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-primary-cta-caret"
style={({ hovered, pressed, open }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
]}
accessibilityRole="button"
accessibilityLabel="More options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => {
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
return (
<View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
description={action.description}
onSelect={action.handler}
>
{action.label}
</DropdownMenuItem>
</View>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
) : null}
{gitActions.menu.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-overflow-menu"
hitSlop={8}
style={[styles.iconButton, styles.overflowMenuButton]}
accessibilityRole="button"
accessibilityLabel="More actions"
>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
{gitActions.menu.map((action) => (
<DropdownMenuItem
key={action.id}
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={false}
onSelect={action.handler}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
splitButton: {
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
splitButtonPrimary: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
splitButtonText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
splitButtonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
splitButtonSpinnerOnly: {
transform: [{ scale: 0.8 }],
},
splitButtonCaret: {
width: 28,
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
splitButtonCaretHovered: {
backgroundColor: theme.colors.surface2,
},
iconButton: {
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
overflowMenuButton: {
marginRight: -theme.spacing[2],
},
}));

View File

@@ -23,7 +23,6 @@ import {
GitMerge,
ListChevronsDownUp,
ListChevronsUpDown,
MoreVertical,
RefreshCcw,
Upload,
} from "lucide-react-native";
@@ -47,7 +46,6 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
type ActionStatus,
} from "@/components/ui/dropdown-menu";
import { GitHubIcon } from "@/components/icons/github-icon";
import {
@@ -58,36 +56,11 @@ import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldShowMergeFromBaseAction } from "./git-action-visibility";
// =============================================================================
// Git Actions Data Structure
// =============================================================================
import { type GitActionId, type GitAction, type GitActions } from "@/hooks/use-git-actions";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
type GitActionId =
| "commit"
| "push"
| "view-pr"
| "create-pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
interface GitAction {
id: GitActionId;
label: string;
pendingLabel: string;
successLabel: string;
disabled: boolean;
status: ActionStatus;
description?: string;
icon?: ReactElement;
handler: () => void;
}
interface GitActions {
primary: GitAction | null;
secondary: GitAction[];
menu: GitAction[];
}
// Re-export types from shared hook
export type { GitActionId, GitAction, GitActions } from "@/hooks/use-git-actions";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
@@ -466,13 +439,14 @@ interface GitDiffPaneProps {
serverId: string;
workspaceId?: string | null;
cwd: string;
hideHeaderRow?: boolean;
}
type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -1219,119 +1193,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
]);
// Helper to get display label based on status
const getActionDisplayLabel = useCallback((action: GitAction): string => {
if (action.status === "pending") return action.pendingLabel;
if (action.status === "success") return action.successLabel;
return action.label;
}, []);
return (
<View style={styles.container}>
<View style={styles.header} testID="changes-header">
<View style={styles.headerLeft}>
<GitBranch size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel}
</Text>
</View>
{isGit ? (
<View style={styles.headerRight}>
{gitActions.primary ? (
<View style={styles.splitButton}>
<Pressable
testID="changes-primary-cta"
style={[
styles.splitButtonPrimary,
gitActions.primary.disabled && styles.splitButtonPrimaryDisabled,
]}
onPress={gitActions.primary.handler}
disabled={gitActions.primary.disabled}
accessibilityRole="button"
accessibilityLabel={gitActions.primary.label}
>
{gitActions.primary.status === "pending" ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
style={styles.splitButtonSpinnerOnly}
/>
) : (
<View style={styles.splitButtonContent}>
{gitActions.primary.icon}
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
</View>
)}
</Pressable>
{gitActions.secondary.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-primary-cta-caret"
style={styles.splitButtonCaret}
accessibilityRole="button"
accessibilityLabel="More options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => {
const needsSeparator = action.id === "merge-from-base" || action.id === "push";
return (
<View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={action.status === "idle" && action.id === "view-pr"}
description={action.description}
onSelect={action.handler}
>
{action.label}
</DropdownMenuItem>
</View>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
) : null}
{gitActions.menu.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-overflow-menu"
hitSlop={8}
style={[styles.iconButton, styles.overflowMenuButton]}
accessibilityRole="button"
accessibilityLabel="More actions"
>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
{gitActions.menu.map((action) => (
<DropdownMenuItem
key={action.id}
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={false}
onSelect={action.handler}
>
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{!hideHeaderRow ? (
<View style={styles.header} testID="changes-header">
<View style={styles.headerLeft}>
<GitBranch size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel}
</Text>
</View>
) : null}
</View>
{isGit ? (
<GitActionsSplitButton gitActions={gitActions} />
) : null}
</View>
) : null}
{isGit ? (
<View style={styles.diffStatusContainer}>
@@ -1438,12 +1315,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
headerRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
branchLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
@@ -1496,112 +1367,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.base,
},
splitButton: {
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
splitButtonPrimary: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
splitButtonText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
splitButtonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
splitButtonSpinnerOnly: {
transform: [{ scale: 0.8 }],
},
splitButtonCaret: {
width: 36,
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
iconButton: {
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
overflowMenuButton: {
marginRight: -theme.spacing[2],
},
menuOverlay: {
flex: 1,
},
menuBackdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
dropdownMenu: {
backgroundColor: theme.colors.surface0,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
},
menuItem: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
menuItemSelected: {
backgroundColor: theme.colors.surface2,
},
menuItemDisabled: {
opacity: 0.5,
},
menuItemText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
menuHintText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
menuItemDestructive: {
backgroundColor: "rgba(248, 81, 73, 0.08)",
},
menuItemTextDestructive: {
color: theme.colors.destructive,
},
menuDivider: {
height: 1,
backgroundColor: theme.colors.border,
},
actionErrorText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[1],

View File

@@ -1,4 +1,4 @@
import type { PropsWithChildren, ReactElement } from "react";
import type { ReactElement, ReactNode } from "react";
import {
Platform,
Text,
@@ -12,6 +12,21 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
interface HeaderToggleButtonState {
hovered: boolean;
pressed: boolean;
}
interface HeaderToggleButtonProps extends Omit<PressableProps, "style" | "onPress" | "children"> {
onPress: NonNullable<PressableProps["onPress"]>;
tooltipLabel: string;
tooltipKeys: ShortcutKey[];
tooltipSide: "left" | "right" | "top" | "bottom";
tooltipDelayDuration?: number;
style?: StyleProp<ViewStyle>;
children: ReactNode | ((state: HeaderToggleButtonState) => ReactNode);
}
export function HeaderToggleButton({
onPress,
tooltipLabel,
@@ -22,16 +37,7 @@ export function HeaderToggleButton({
disabled,
children,
...props
}: PropsWithChildren<
Omit<PressableProps, "style" | "onPress"> & {
onPress: NonNullable<PressableProps["onPress"]>;
tooltipLabel: string;
tooltipKeys: ShortcutKey[];
tooltipSide: "left" | "right" | "top" | "bottom";
tooltipDelayDuration?: number;
style?: StyleProp<ViewStyle>;
}
>): ReactElement {
}: HeaderToggleButtonProps): ReactElement {
const tooltipTestID =
typeof props.testID === "string" && props.testID.length > 0
? `${props.testID}-tooltip`
@@ -53,7 +59,10 @@ export function HeaderToggleButton({
}}
style={[styles.button, style]}
>
{children}
{typeof children === "function"
? (state: { pressed: boolean; hovered?: boolean }) =>
children({ hovered: Boolean(state.hovered), pressed: state.pressed })
: children}
</TooltipTrigger>
<TooltipContent testID={tooltipTestID} side={tooltipSide} align="center" offset={8}>
<View style={styles.tooltipRow}>

View File

@@ -0,0 +1,32 @@
import Svg, { Rect, Line } from "react-native-svg";
interface SourceControlPanelIconProps {
size?: number;
color?: string;
}
export function SourceControlPanelIcon({
size = 16,
color = "currentColor",
}: SourceControlPanelIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Rect
x={3}
y={3}
width={18}
height={18}
rx={2}
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Plus */}
<Line x1={9} y1={9.5} x2={15} y2={9.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
<Line x1={12} y1={6.5} x2={12} y2={12.5} stroke={color} strokeWidth={2} strokeLinecap="round" />
{/* Minus */}
<Line x1={9} y1={16} x2={15} y2={16} stroke={color} strokeWidth={2} strokeLinecap="round" />
</Svg>
);
}

View File

@@ -339,7 +339,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
hovered && styles.newAgentButtonTextHovered,
]}
>
Open project
Add project
</Text>
</>
)}
@@ -463,7 +463,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
<Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
Open project
Add project
</Text>
</>
)}

View File

@@ -101,6 +101,7 @@ export interface MessageInputRef {
const MIN_INPUT_HEIGHT = 30
const MAX_INPUT_HEIGHT = 160
const IS_WEB = Platform.OS === 'web'
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
@@ -112,12 +113,68 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
type TextAreaHandle = {
scrollHeight?: number
clientHeight?: number
offsetHeight?: number
scrollTop?: number
selectionStart?: number | null
selectionEnd?: number | null
style?: {
height?: string
overflowY?: string
} & Record<string, unknown>
}
function logWebStickyBottom(
event: string,
details: Record<string, unknown>
): void {
if (!IS_DEV || !IS_WEB) {
return
}
console.log('[WebStickyBottom]', event, details)
}
function getDebugNow(): number | null {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return Number(performance.now().toFixed(3))
}
return null
}
function getElementDescriptor(element: HTMLElement | null): string | null {
if (!element) return null
const tag = element.tagName?.toLowerCase() ?? 'unknown'
const id = element.id ? `#${element.id}` : ''
const testId = element.getAttribute?.('data-testid')
const label = element.getAttribute?.('aria-label')
const suffix = testId
? `[data-testid="${testId}"]`
: label
? `[aria-label="${label}"]`
: ''
return `${tag}${id}${suffix}`
}
function getScrollableAncestorChain(element: HTMLElement | null): string[] {
if (!element || typeof window === 'undefined') {
return []
}
const results: string[] = []
let current = element.parentElement
while (current) {
const style = window.getComputedStyle(current)
const overflowY = style.overflowY
const canScroll =
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
current.scrollHeight > current.clientHeight
if (canScroll) {
results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase())
}
current = current.parentElement
}
return results
}
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
const uri = useAttachmentPreviewUrl(image)
if (!uri) {
@@ -164,6 +221,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const toast = useToast()
const voice = useVoiceOptional()
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
const rootRef = useRef<View | null>(null)
const inputWrapperRef = useRef<View | null>(null)
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
null
)
@@ -509,6 +568,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return null
}, [])
const getWebElement = useCallback((target: 'root' | 'wrapper'): HTMLElement | null => {
const ref = target === 'root' ? rootRef.current : inputWrapperRef.current
if (!ref) return null
return ref instanceof HTMLElement ? ref : ((ref as unknown as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect ? (ref as unknown as HTMLElement) : null)
}, [])
useEffect(() => {
if (!IS_WEB || !onAddImages) {
return
@@ -562,36 +627,125 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onAddImages,
])
useEffect(() => {
if (!IS_WEB || typeof ResizeObserver === 'undefined') {
return
}
const textarea = getWebTextArea()
const root = getWebElement('root')
const wrapper = getWebElement('wrapper')
const observed = [
{ name: 'composer_root', element: root },
{ name: 'composer_wrapper', element: wrapper },
{ name: 'composer_textarea', element: textarea as unknown as HTMLElement | null },
].filter((entry): entry is { name: string; element: HTMLElement } => entry.element instanceof HTMLElement)
if (observed.length === 0) {
return
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement
const match = observed.find((item) => item.element === target)
if (!match) {
continue
}
const textareaNode = getWebTextArea()
logWebStickyBottom('composer_element_resized', {
target: match.name,
width: target.clientWidth,
height: target.clientHeight,
offsetHeight: target.offsetHeight,
scrollHeight: target.scrollHeight,
textareaClientHeight: textareaNode?.clientHeight ?? null,
textareaOffsetHeight: textareaNode?.offsetHeight ?? null,
textareaScrollHeight: textareaNode?.scrollHeight ?? null,
textareaScrollTop: (textareaNode as unknown as HTMLTextAreaElement | null)?.scrollTop ?? null,
valueLength: valueRef.current.length,
})
}
})
for (const entry of observed) {
observer.observe(entry.element)
}
return () => {
observer.disconnect()
}
}, [getWebElement, getWebTextArea])
useEffect(() => {
if (!IS_WEB) {
return
}
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null
if (!textarea || typeof textarea.addEventListener !== 'function') {
return
}
const handleScroll = () => {
const textareaElement = textarea as unknown as HTMLElement
const chatScroller =
typeof document !== 'undefined'
? (document.querySelector('[data-testid="agent-chat-scroll"]') as HTMLElement | null)
: null
logWebStickyBottom('composer_textarea_scrolled', {
now: getDebugNow(),
scrollTop: textarea.scrollTop,
clientHeight: textarea.clientHeight ?? null,
scrollHeight: textarea.scrollHeight ?? null,
selectionStart: textarea.selectionStart ?? null,
selectionEnd: textarea.selectionEnd ?? null,
textareaDescriptor: getElementDescriptor(textareaElement),
chatScrollerDescriptor: getElementDescriptor(chatScroller),
chatScrollerContainsTextarea: Boolean(chatScroller && textareaElement && chatScroller.contains(textareaElement)),
textareaScrollableAncestors: getScrollableAncestorChain(textareaElement),
valueLength: valueRef.current.length,
})
}
textarea.addEventListener('scroll', handleScroll, { passive: true })
return () => {
textarea.removeEventListener('scroll', handleScroll)
}
}, [getWebTextArea])
function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) return false
const textarea = getWebTextArea()
if (!textarea || typeof textarea.scrollHeight !== 'number') return false
const prevHeight = textarea.style?.height
const prevOverflow = textarea.style?.overflowY
if (textarea.style) {
textarea.style.height = 'auto'
textarea.style.overflowY = 'hidden'
}
const scrollHeight = textarea.scrollHeight ?? 0
if (textarea.style) {
textarea.style.height = prevHeight ?? ''
textarea.style.overflowY = prevOverflow ?? ''
}
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
baselineInputHeightRef.current = scrollHeight
logWebStickyBottom('composer_baseline_measured', {
source,
baseline: scrollHeight,
})
}
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget))
if (Math.abs(inputHeightRef.current - bounded) >= 1) {
const previousHeight = inputHeightRef.current
if (Math.abs(previousHeight - bounded) >= 1) {
inputHeightRef.current = bounded
setInputHeight(bounded)
onHeightChange?.(bounded)
logWebStickyBottom('composer_height_changed', {
source,
previousHeight,
nextHeight: bounded,
scrollHeight,
clientHeight: textarea.clientHeight ?? null,
offsetHeight: textarea.offsetHeight ?? null,
baseline,
rawTarget,
})
return true
}
return false
@@ -600,25 +754,51 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
function setBoundedInputHeight(nextHeight: number) {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight))
if (Math.abs(inputHeightRef.current - bounded) < 1) return
const previousHeight = inputHeightRef.current
inputHeightRef.current = bounded
setInputHeight(bounded)
onHeightChange?.(bounded)
logWebStickyBottom('composer_height_changed_native', {
previousHeight,
nextHeight: bounded,
})
}
function handleContentSizeChange(
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>
) {
const contentHeight = event.nativeEvent.contentSize.height
if (IS_WEB) {
measureWebInputHeight('contentSizeChange')
logWebStickyBottom('composer_content_size_change', {
reportedHeight: contentHeight,
})
if (baselineInputHeightRef.current === null && contentHeight > 0) {
baselineInputHeightRef.current = contentHeight
logWebStickyBottom('composer_baseline_measured', {
source: 'contentSizeChange',
baseline: contentHeight,
})
}
setBoundedInputHeight(contentHeight)
return
}
const contentHeight = event.nativeEvent.contentSize.height
setBoundedInputHeight(contentHeight)
}
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
const start = event.nativeEvent.selection?.start ?? 0
const end = event.nativeEvent.selection?.end ?? start
if (IS_WEB) {
const textarea = getWebTextArea()
logWebStickyBottom('composer_selection_changed', {
now: getDebugNow(),
start,
end,
textareaScrollTop: textarea?.scrollTop ?? null,
textareaClientHeight: textarea?.clientHeight ?? null,
textareaScrollHeight: textarea?.scrollHeight ?? null,
})
}
onSelectionChangeCallback?.({ start, end })
}
@@ -674,14 +854,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, 'inputChange')
onChangeText(nextValue)
if (IS_WEB) {
logWebStickyBottom('composer_text_changed', {
valueLength: nextValue.length,
lineCount: nextValue.split('\n').length,
})
}
},
[investigationComponentId, onChangeText]
)
return (
<View style={styles.container} testID="message-input-root">
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View style={[styles.inputWrapper, inputAnimatedStyle]}>
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputAnimatedStyle]}>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">

View File

@@ -155,7 +155,7 @@ export function ProjectPickerModal() {
// Keyboard navigation
useEffect(() => {
if (!open) return;
if (!open || Platform.OS !== "web") return;
function handler(event: KeyboardEvent) {
const key = event.key;
@@ -200,7 +200,6 @@ export function ProjectPickerModal() {
return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]);
if (Platform.OS !== "web") return null;
if (!serverId) return null;
return (

View File

@@ -665,11 +665,6 @@ function WorkspaceRowInner({
</Text>
</View>
<View style={styles.workspaceRowRight}>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
{onArchive && (isHovered || isMobile) ? (
<DropdownMenu>
<DropdownMenuTrigger
@@ -716,6 +711,11 @@ function WorkspaceRowInner({
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
</Pressable>
</View>
@@ -1772,7 +1772,7 @@ const styles = StyleSheet.create((theme) => ({
shortcutBadgeText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: '600',
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
},
}))

View File

@@ -24,6 +24,7 @@ const USER_SCROLL_DELTA_EPSILON = 1
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1
const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style'
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
const WEB_STREAM_SCROLLBAR_STYLE = `
#agent-chat-scroll-web-dom-scroll,
#agent-chat-scroll-web-dom-virtualized {
@@ -39,6 +40,20 @@ const WEB_STREAM_SCROLLBAR_STYLE = `
}
`
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
if (!IS_DEV) {
return
}
console.log('[WebStickyBottom]', event, details)
}
function getDebugNow(): number | null {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return Number(performance.now().toFixed(3))
}
return null
}
function isScrollContainerNearBottom(
scrollContainer: Pick<HTMLElement, 'scrollTop' | 'clientHeight' | 'scrollHeight'>,
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX
@@ -119,6 +134,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
const followOutputRef = useRef(followOutput)
const lastKnownScrollTopRef = useRef(0)
const lastLoggedMetricsRef = useRef<{
scrollTop: number
clientWidth: number
clientHeight: number
scrollWidth: number
scrollHeight: number
} | null>(null)
const pendingUserScrollUpIntentRef = useRef(false)
const isPointerScrollActiveRef = useRef(false)
const lastTouchClientYRef = useRef<number | null>(null)
@@ -156,6 +178,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const viewportHeight = instance.scrollRect?.height ?? 0
const scrollOffset = instance.scrollOffset ?? 0
const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight)
logWebStickyBottom('virtualizer_item_size_change', {
agentId: props.agentId,
delta: _delta,
itemIndex: _item.index,
itemStart: _item.start,
itemSize: _item.size,
viewportHeight,
scrollOffset,
totalSize: instance.getTotalSize(),
remainingDistance,
})
return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX
}
return () => {
@@ -187,11 +220,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
return
}
logWebStickyBottom('viewport_scroll_to_bottom', {
agentId: props.agentId,
behavior,
followOutput: followOutputRef.current,
scrollTop: scrollContainer.scrollTop,
clientWidth: scrollContainer.clientWidth,
clientHeight: scrollContainer.clientHeight,
scrollWidth: scrollContainer.scrollWidth,
scrollHeight: scrollContainer.scrollHeight,
})
scrollElementToBottom(scrollContainer, behavior)
lastKnownScrollTopRef.current = scrollContainer.scrollTop
syncNearBottom(scrollContainer, onNearBottomChange)
},
[onNearBottomChange]
[onNearBottomChange, props.agentId]
)
const scheduleStickToBottom = useCallback(
@@ -203,6 +246,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (pendingAutoScrollFrameRef.current !== null) {
return
}
logWebStickyBottom('viewport_schedule_stick_to_bottom', {
agentId: props.agentId,
followOutput: followOutputRef.current,
scrollTop: scrollContainer?.scrollTop ?? null,
clientWidth: scrollContainer?.clientWidth ?? null,
clientHeight: scrollContainer?.clientHeight ?? null,
scrollWidth: scrollContainer?.scrollWidth ?? null,
scrollHeight: scrollContainer?.scrollHeight ?? null,
})
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
pendingAutoScrollFrameRef.current = null
if (!followOutputRef.current) {
@@ -211,7 +263,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollMessagesToBottom('auto')
})
},
[scrollMessagesToBottom]
[props.agentId, scrollMessagesToBottom]
)
const forceStickToBottom = useCallback(() => {
@@ -254,7 +306,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
},
} as never)
syncNearBottom(scrollContainer, onNearBottomChange)
}, [onNearBottomChange, streamScrollbarMetrics])
const currentMetrics = {
scrollTop: scrollContainer.scrollTop,
clientWidth: scrollContainer.clientWidth,
clientHeight: scrollContainer.clientHeight,
scrollWidth: scrollContainer.scrollWidth,
scrollHeight: scrollContainer.scrollHeight,
}
const previousMetrics = lastLoggedMetricsRef.current
const shouldLog =
!previousMetrics ||
previousMetrics.scrollTop !== currentMetrics.scrollTop ||
previousMetrics.clientWidth !== currentMetrics.clientWidth ||
previousMetrics.clientHeight !== currentMetrics.clientHeight ||
previousMetrics.scrollWidth !== currentMetrics.scrollWidth ||
previousMetrics.scrollHeight !== currentMetrics.scrollHeight
if (shouldLog) {
lastLoggedMetricsRef.current = currentMetrics
logWebStickyBottom('viewport_metrics_updated', {
agentId: props.agentId,
followOutput: followOutputRef.current,
distanceFromBottom: getScrollContainerDistanceFromBottom(scrollContainer),
...currentMetrics,
})
}
}, [onNearBottomChange, props.agentId, streamScrollbarMetrics])
const handleDomScroll = useCallback(() => {
const scrollContainer = scrollContainerRef.current
@@ -283,6 +359,19 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
lastKnownScrollTopRef.current = currentScrollTop
logWebStickyBottom('viewport_dom_scroll', {
agentId: props.agentId,
now: getDebugNow(),
scrollTop: currentScrollTop,
clientHeight: scrollContainer.clientHeight,
scrollHeight: scrollContainer.scrollHeight,
activeElementTag:
typeof document !== 'undefined' ? document.activeElement?.tagName?.toLowerCase() ?? null : null,
activeElementRole:
typeof document !== 'undefined'
? document.activeElement?.getAttribute?.('aria-label') ?? null
: null,
})
updateScrollMetrics()
}, [cancelPendingStickToBottom, updateScrollMetrics])
@@ -348,6 +437,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
updateScrollMetrics()
const observer = new ResizeObserver(() => {
logWebStickyBottom('viewport_resize_observed', {
agentId: props.agentId,
followOutput: followOutputRef.current,
scrollTop: scrollContainer.scrollTop,
clientWidth: scrollContainer.clientWidth,
clientHeight: scrollContainer.clientHeight,
scrollWidth: scrollContainer.scrollWidth,
scrollHeight: scrollContainer.scrollHeight,
})
updateScrollMetrics()
if (!followOutputRef.current) {
return
@@ -361,7 +459,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
observer.disconnect()
}
}, [scheduleStickToBottom, updateScrollMetrics])
}, [props.agentId, scheduleStickToBottom, updateScrollMetrics])
useEffect(() => {
const scrollContainer = scrollContainerRef.current
@@ -369,6 +467,59 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return
}
const originalScrollTo = scrollContainer.scrollTo.bind(scrollContainer)
const scrollTopDescriptor =
Object.getOwnPropertyDescriptor(Object.getPrototypeOf(scrollContainer), 'scrollTop') ??
Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
scrollContainer.scrollTo = ((...args: Parameters<HTMLElement['scrollTo']>) => {
const firstArg = args[0] as ScrollToOptions | number | undefined
const target =
typeof firstArg === 'object' && firstArg !== null
? {
top: firstArg.top ?? null,
left: firstArg.left ?? null,
behavior: firstArg.behavior ?? null,
}
: {
top: typeof args[1] === 'number' ? args[1] : null,
left: typeof firstArg === 'number' ? firstArg : null,
behavior: null,
}
logWebStickyBottom('viewport_scroll_to_called', {
agentId: props.agentId,
now: getDebugNow(),
currentScrollTop: scrollContainer.scrollTop,
target,
stack:
typeof Error !== 'undefined'
? new Error().stack?.split('\n').slice(1, 6).join('\n') ?? null
: null,
})
return originalScrollTo(...args)
}) as typeof scrollContainer.scrollTo
if (scrollTopDescriptor?.get && scrollTopDescriptor?.set) {
Object.defineProperty(scrollContainer, 'scrollTop', {
configurable: true,
enumerable: scrollTopDescriptor.enumerable ?? false,
get() {
return scrollTopDescriptor.get?.call(scrollContainer)
},
set(value: number) {
logWebStickyBottom('viewport_scroll_top_set', {
agentId: props.agentId,
now: getDebugNow(),
currentScrollTop: scrollTopDescriptor.get?.call(scrollContainer) ?? null,
nextScrollTop: value,
stack:
typeof Error !== 'undefined'
? new Error().stack?.split('\n').slice(1, 6).join('\n') ?? null
: null,
})
return scrollTopDescriptor.set?.call(scrollContainer, value)
},
})
}
const handleWheel = (event: WheelEvent) => {
if (event.deltaY < 0) {
pendingUserScrollUpIntentRef.current = true
@@ -403,6 +554,25 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const handleTouchEnd = () => {
lastTouchClientYRef.current = null
}
const handleSelectionChange = () => {
const activeElement =
typeof document !== 'undefined' ? (document.activeElement as HTMLTextAreaElement | null) : null
logWebStickyBottom('document_selection_changed', {
agentId: props.agentId,
now: getDebugNow(),
activeElementTag: activeElement?.tagName?.toLowerCase() ?? null,
activeElementRole: activeElement?.getAttribute?.('aria-label') ?? null,
selectionStart:
activeElement && typeof activeElement.selectionStart === 'number'
? activeElement.selectionStart
: null,
selectionEnd:
activeElement && typeof activeElement.selectionEnd === 'number'
? activeElement.selectionEnd
: null,
scrollTop: scrollContainer.scrollTop,
})
}
scrollContainer.addEventListener('scroll', handleDomScroll, { passive: true })
scrollContainer.addEventListener('wheel', handleWheel, { passive: true })
@@ -413,6 +583,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: true })
scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: true })
scrollContainer.addEventListener('touchcancel', handleTouchEnd, { passive: true })
if (typeof document !== 'undefined') {
document.addEventListener('selectionchange', handleSelectionChange, { passive: true })
}
return () => {
scrollContainer.removeEventListener('scroll', handleDomScroll)
@@ -424,8 +597,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scrollContainer.removeEventListener('touchmove', handleTouchMove)
scrollContainer.removeEventListener('touchend', handleTouchEnd)
scrollContainer.removeEventListener('touchcancel', handleTouchEnd)
scrollContainer.scrollTo = originalScrollTo
if (scrollTopDescriptor) {
Reflect.deleteProperty(scrollContainer, 'scrollTop')
}
if (typeof document !== 'undefined') {
document.removeEventListener('selectionchange', handleSelectionChange)
}
}
}, [cancelPendingStickToBottom, handleDomScroll])
}, [cancelPendingStickToBottom, handleDomScroll, props.agentId])
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -438,6 +618,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
if (!followOutputRef.current) {
return
}
const scrollContainer = scrollContainerRef.current
logWebStickyBottom('viewport_prepare_for_change', {
agentId: props.agentId,
followOutput: followOutputRef.current,
scrollTop: scrollContainer?.scrollTop ?? null,
clientWidth: scrollContainer?.clientWidth ?? null,
clientHeight: scrollContainer?.clientHeight ?? null,
scrollWidth: scrollContainer?.scrollWidth ?? null,
scrollHeight: scrollContainer?.scrollHeight ?? null,
})
scheduleStickToBottom()
},
}
@@ -448,7 +638,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}
cancelPendingStickToBottom()
}
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef])
}, [cancelPendingStickToBottom, forceStickToBottom, props.agentId, scheduleStickToBottom, viewportRef])
const contentContainerStyle = useMemo(
(): CSSProperties => ({

View File

@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useRef } from 'react'
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useQuery, useQueryClient } from '@tanstack/react-query'
@@ -99,6 +99,7 @@ export type DesktopStartupReconciliationInput = {
interface DaemonRegistryContextValue {
daemons: HostProfile[]
isLoading: boolean
isReconciling: boolean
error: unknown | null
upsertDirectConnection: (input: {
serverId: string
@@ -565,6 +566,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient()
const desktopStartupReconciledRef = useRef(false)
const localhostBootstrapAttemptedRef = useRef(false)
const [isReconciling, setIsReconciling] = useState(true)
const { settings, isLoading: settingsLoading } = useAppSettings()
const {
data: daemons = [],
@@ -761,7 +763,11 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
}
}
void reconcileDesktopStartup()
void reconcileDesktopStartup().finally(() => {
if (!cancelled) {
setIsReconciling(false)
}
})
return () => {
cancelled = true
@@ -826,7 +832,11 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
}
}
void bootstrapLocalhost()
void bootstrapLocalhost().finally(() => {
if (!cancelled) {
setIsReconciling(false)
}
})
return () => {
cancelled = true
@@ -895,6 +905,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
const value: DaemonRegistryContextValue = {
daemons,
isLoading: isPending,
isReconciling,
error: error ?? null,
upsertDirectConnection,
upsertRelayConnection,

View File

@@ -0,0 +1,445 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import { useRouter } from "expo-router";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { shouldShowMergeFromBaseAction } from "@/components/git-action-visibility";
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
export type GitActionId =
| "commit"
| "push"
| "view-pr"
| "create-pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
export interface GitAction {
id: GitActionId;
label: string;
pendingLabel: string;
successLabel: string;
disabled: boolean;
status: ActionStatus;
description?: string;
icon?: ReactElement;
handler: () => void;
}
export interface GitActions {
primary: GitAction | null;
secondary: GitAction[];
menu: GitAction[];
}
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
interface UseGitActionsInput {
serverId: string;
cwd: string;
icons: {
commit: ReactElement;
push: ReactElement;
viewPr: ReactElement;
createPr: ReactElement;
merge: ReactElement;
mergeFromBase: ReactElement;
archive: ReactElement;
};
}
interface UseGitActionsResult {
gitActions: GitActions;
branchLabel: string;
actionError: string | null;
isGit: boolean;
}
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
const router = useRouter();
const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { status, isLoading: isStatusLoading } =
useCheckoutStatusQuery({ serverId, cwd });
const gitStatus = status && status.isGit ? status : null;
const isGit = Boolean(gitStatus);
const notGit = status !== null && !status.isGit && !status.error;
const baseRef = gitStatus?.baseRef ?? undefined;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const {
status: prStatus,
githubFeaturesEnabled,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
// Ship default persistence
const shipDefaultStorageKey = useMemo(() => {
if (!gitStatus?.repoRoot) {
return null;
}
return `@paseo:changes-ship-default:${gitStatus.repoRoot}`;
}, [gitStatus?.repoRoot]);
useEffect(() => {
if (!shipDefaultStorageKey) {
return;
}
let isActive = true;
AsyncStorage.getItem(shipDefaultStorageKey)
.then((value) => {
if (!isActive) return;
if (value === "pr" || value === "merge") {
setShipDefault(value);
}
})
.catch(() => undefined);
return () => {
isActive = false;
};
}, [shipDefaultStorageKey]);
const persistShipDefault = useCallback(
async (next: "merge" | "pr") => {
setShipDefault(next);
if (!shipDefaultStorageKey) return;
try {
await AsyncStorage.setItem(shipDefaultStorageKey, next);
} catch {
// Ignore persistence failures; default will reset to "merge".
}
},
[shipDefaultStorageKey]
);
useEffect(() => {
setPostShipArchiveSuggested(false);
}, [cwd]);
// Store selectors
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" })
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" })
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
// Handlers
const handleCommit = useCallback(() => {
setActionError(null);
void runCommit({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to commit";
setActionError(message);
});
}, [runCommit, serverId, cwd]);
const handlePush = useCallback(() => {
setActionError(null);
void runPush({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to push";
setActionError(message);
});
}, [runPush, serverId, cwd]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
setActionError(null);
void runCreatePr({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
});
}, [persistShipDefault, runCreatePr, serverId, cwd]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
setPostShipArchiveSuggested(true);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
setActionError(null);
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
});
}, [baseRef, runMergeFromBase, serverId, cwd]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
setActionError("Worktree path unavailable");
return;
}
setActionError(null);
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
});
}, [runArchiveWorktree, router, serverId, cwd, status]);
// Derived state
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
const baseRefLabel = useMemo(() => {
if (!baseRef) return "base";
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}, [baseRef]);
const hasPullRequest = Boolean(prStatus?.url);
const hasRemote = gitStatus?.hasRemote ?? false;
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
const isMergedPullRequest = Boolean(prStatus?.isMerged);
const currentBranch = gitStatus?.currentBranch;
const isOnBaseBranch = currentBranch === baseRefLabel;
const shouldPromoteArchive =
isPaseoOwnedWorktree &&
!hasUncommittedChanges &&
(postShipArchiveSuggested || isMergedPullRequest);
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
const mergeFromBaseDisabled =
actionsDisabled ||
mergeFromBaseStatus === "pending" ||
hasUncommittedChanges ||
!baseRef ||
(isOnBaseBranch && !hasRemote);
const pushDisabled =
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
const archiveDisabled =
actionsDisabled ||
archiveStatus === "pending" ||
!gitStatus?.isPaseoOwnedWorktree;
const branchLabel =
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
? gitStatus.currentBranch
: notGit
? "Not a git repository"
: "Unknown";
// Build actions
const gitActions: GitActions = useMemo(() => {
if (!isGit) {
return { primary: null, secondary: [], menu: [] };
}
const allActions = new Map<GitActionId, GitAction>();
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: commitDisabled,
status: commitStatus,
icon: icons.commit,
handler: handleCommit,
});
if (hasRemote) {
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: pushDisabled,
status: pushStatus,
description: !hasRemote ? "No remote configured" : undefined,
icon: icons.push,
handler: handlePush,
});
}
if (githubFeaturesEnabled && hasPullRequest && prStatus?.url) {
const prUrl = prStatus.url;
allActions.set("view-pr", {
id: "view-pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: false,
status: "idle",
icon: icons.viewPr,
handler: () => openURLInNewTab(prUrl),
});
}
if (githubFeaturesEnabled && aheadCount > 0 && !hasPullRequest) {
allActions.set("create-pr", {
id: "create-pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateStatus,
icon: icons.createPr,
handler: handleCreatePr,
});
}
if (aheadCount > 0) {
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
status: mergeStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
icon: icons.merge,
handler: handleMergeBranch,
});
}
if (
shouldShowMergeFromBaseAction({
isOnBaseBranch,
hasRemote,
aheadOfOrigin,
behindOfOrigin,
})
) {
allActions.set("merge-from-base", {
id: "merge-from-base",
label: isOnBaseBranch ? "Sync" : `Update from ${baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseStatus,
description:
hasUncommittedChanges
? "Requires clean working tree"
: isOnBaseBranch && !hasRemote
? "No remote configured"
: undefined,
icon: icons.mergeFromBase,
handler: handleMergeFromBase,
});
}
if (isPaseoOwnedWorktree) {
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: archiveDisabled,
status: archiveStatus,
icon: icons.archive,
handler: handleArchiveWorktree,
});
}
// Select primary action (priority rules)
let primaryActionId: GitActionId | null = null;
if (shouldPromoteArchive && allActions.has("archive-worktree")) {
primaryActionId = "archive-worktree";
} else if (hasUncommittedChanges) {
primaryActionId = "commit";
} else if (aheadOfOrigin > 0 && allActions.has("push")) {
primaryActionId = "push";
} else if (hasPullRequest) {
primaryActionId = "view-pr";
} else if (isOnBaseBranch && allActions.has("merge-from-base")) {
primaryActionId = "merge-from-base";
} else if (aheadCount > 0) {
const preferred: GitActionId = shipDefault === "merge" ? "merge-branch" : "create-pr";
const fallback: GitActionId = shipDefault === "merge" ? "create-pr" : "merge-branch";
const preferredAction = allActions.get(preferred);
const fallbackAction = allActions.get(fallback);
if (preferredAction && !preferredAction.disabled) {
primaryActionId = preferred;
} else if (fallbackAction && !fallbackAction.disabled) {
primaryActionId = fallback;
} else if (preferredAction) {
primaryActionId = preferred;
}
}
const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null;
const secondaryIds: GitActionId[] = [
"merge-branch",
"create-pr",
"view-pr",
"merge-from-base",
"push",
"archive-worktree",
];
const secondary = secondaryIds
.filter(id => id !== primaryActionId && allActions.has(id))
.map(id => allActions.get(id)!);
const menu: GitAction[] = [];
return { primary, secondary, menu };
}, [
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled,
hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
icons, baseRef,
]);
return { gitActions, branchLabel, actionError, isGit };
}

View File

@@ -74,6 +74,13 @@ function logAgentExplorer(event: string, details: Record<string, unknown>): void
console.log(`[AgentExplorer] ${event}`, details);
}
function logWebStickyBottom(event: string, details: Record<string, unknown>): void {
if (!IS_DEV || Platform.OS !== "web") {
return;
}
console.log("[WebStickyBottom]", event, details);
}
export function AgentReadyScreen({
serverId,
agentId,
@@ -946,12 +953,19 @@ function AgentScreenContent({
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={() =>
streamViewRef.current?.prepareForViewportChange()
}
onMessageSent={() =>
streamViewRef.current?.scrollToBottom("message-sent")
}
onComposerHeightChange={(height) => {
logWebStickyBottom("screen_composer_height_change", {
agentId: resolvedAgentId,
height,
});
streamViewRef.current?.prepareForViewportChange();
}}
onMessageSent={() => {
logWebStickyBottom("screen_message_sent_scroll_to_bottom", {
agentId: resolvedAgentId,
});
streamViewRef.current?.scrollToBottom("message-sent");
}}
/>
)}

View File

@@ -1,17 +1,34 @@
import { useEffect } from "react";
import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { FolderOpen } from "lucide-react-native";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { usePanelStore } from "@/stores/panel-store";
export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const setProjectPickerOpen = useKeyboardShortcutsStore(
(s) => s.setProjectPickerOpen
);
const openAgentList = usePanelStore((s) => s.openAgentList);
useEffect(() => {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
if (!isMobile) {
openAgentList();
}
}, [openAgentList]);
return (
<View style={styles.container}>
<View style={[styles.menuToggle, { paddingTop: insets.top }]}>
<SidebarMenuToggle />
</View>
<View style={styles.content}>
<PaseoLogo size={56} />
<Text style={styles.heading}>What shall we build today?</Text>
@@ -24,7 +41,7 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
testID="open-project-submit"
>
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.openButtonText}>Open a project</Text>
<Text style={styles.openButtonText}>Add a project</Text>
</Pressable>
</View>
</View>
@@ -36,6 +53,12 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
backgroundColor: theme.colors.surface0,
},
menuToggle: {
position: "absolute",
top: theme.spacing[3],
left: theme.spacing[3],
zIndex: 1,
},
content: {
flexGrow: 1,
justifyContent: "center",

View File

@@ -0,0 +1,33 @@
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.colors.surface0,
},
logo: {
width: 96,
height: 96,
marginBottom: theme.spacing[6],
},
status: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
}));
export function StartupSplashScreen() {
return (
<View style={styles.container}>
<Image
source={require("../../assets/images/icon.png")}
style={styles.logo}
resizeMode="contain"
/>
<Text style={styles.status}>Starting up</Text>
</View>
);
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
import { Plus, SquareTerminal, X } from "lucide-react-native";
import { Plus, X } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list";
import {
@@ -22,7 +22,7 @@ import type { Agent } from "@/stores/session-store";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
type NewTabOptionId = "__new_tab_agent__";
type WorkspaceDesktopTabsRowProps = {
tabs: WorkspaceTabDescriptor[];
@@ -44,10 +44,6 @@ type WorkspaceDesktopTabsRowProps = {
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
newTabAgentOptionId: NewTabOptionId;
newTabTerminalOptionId: NewTabOptionId;
createTerminalPending: boolean;
isNewTerminalHovered: boolean;
setIsNewTerminalHovered: Dispatch<SetStateAction<boolean>>;
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
};
@@ -71,10 +67,6 @@ export function WorkspaceDesktopTabsRow({
onCloseOtherTabs,
onSelectNewTabOption,
newTabAgentOptionId,
newTabTerminalOptionId,
createTerminalPending,
isNewTerminalHovered,
setIsNewTerminalHovered,
onReorderTabs,
}: WorkspaceDesktopTabsRowProps) {
const { theme } = useUnistyles();
@@ -378,36 +370,6 @@ export function WorkspaceDesktopTabsRow({
<Text style={styles.newTabTooltipText}>New agent tab</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-terminal-tab"
onPress={() => onSelectNewTabOption(newTabTerminalOptionId)}
onHoverIn={() => setIsNewTerminalHovered(true)}
onHoverOut={() => setIsNewTerminalHovered(false)}
disabled={createTerminalPending}
accessibilityRole="button"
accessibilityLabel="New terminal tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
createTerminalPending && styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
{createTerminalPending ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.terminalPlusIcon}>
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
<Plus size={10} color={theme.colors.foregroundMuted} />
</View>
</View>
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
</TooltipContent>
</Tooltip>
</View>
</View>
);
@@ -522,31 +484,6 @@ const styles = StyleSheet.create((theme) => ({
newTabActionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
newTabActionButtonDisabled: {
opacity: 0.5,
},
terminalPlusIcon: {
width: 16,
height: 16,
alignItems: "center",
justifyContent: "center",
},
terminalPlusBadge: {
position: "absolute",
right: -7,
bottom: -7,
width: 12,
height: 12,
borderRadius: 6,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
borderColor: theme.colors.surface0,
backgroundColor: theme.colors.surface0,
},
terminalPlusBadgeHovered: {
backgroundColor: theme.colors.surface2,
},
newTabTooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,

View File

@@ -0,0 +1,39 @@
import { useMemo } from "react";
import { useUnistyles } from "react-native-unistyles";
import {
Archive,
GitCommitHorizontal,
GitMerge,
RefreshCcw,
Upload,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { useGitActions } from "@/hooks/use-git-actions";
interface WorkspaceGitActionsProps {
serverId: string;
cwd: string;
}
export function WorkspaceGitActions({ serverId, cwd }: WorkspaceGitActionsProps) {
const { theme } = useUnistyles();
const icons = useMemo(() => ({
commit: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
push: <Upload size={16} color={theme.colors.foregroundMuted} />,
viewPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
createPr: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
merge: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
mergeFromBase: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
archive: <Archive size={16} color={theme.colors.foregroundMuted} />,
}), [theme.colors.foregroundMuted]);
const { gitActions, isGit } = useGitActions({ serverId, cwd, icons });
if (!isGit) {
return null;
}
return <GitActionsSplitButton gitActions={gitActions} />;
}

View File

@@ -15,7 +15,7 @@ import {
Copy,
Folder,
GitBranch,
MoreVertical,
Ellipsis,
PanelRight,
Plus,
SquareTerminal,
@@ -40,6 +40,8 @@ import {
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FilePane } from "@/components/file-pane";
import { TerminalPane } from "@/components/terminal-pane";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -98,7 +100,6 @@ import {
const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
const EMPTY_UI_TABS: ReturnType<typeof useWorkspaceTabsStore.getState>["uiTabsByWorkspace"][string] = [];
const EMPTY_TAB_ORDER: string[] = [];
@@ -751,7 +752,6 @@ function WorkspaceScreenContent({
);
const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false);
const [isNewTerminalHovered, setIsNewTerminalHovered] = useState(false);
const [hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(
null
@@ -828,16 +828,12 @@ function WorkspaceScreenContent({
);
const handleSelectNewTabOption = useCallback(
(key: typeof NEW_TAB_AGENT_OPTION_ID | typeof NEW_TAB_TERMINAL_OPTION_ID) => {
(key: typeof NEW_TAB_AGENT_OPTION_ID) => {
if (key === NEW_TAB_AGENT_OPTION_ID) {
handleCreateDraftTab();
return;
}
if (key === NEW_TAB_TERMINAL_OPTION_ID) {
handleCreateTerminal();
}
},
[handleCreateDraftTab, handleCreateTerminal]
[handleCreateDraftTab]
);
const handleCloseTerminalTab = useCallback(
@@ -1349,87 +1345,134 @@ function WorkspaceScreenContent({
</Text>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={styles.headerActionButton}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
>
{({ hovered, open }) => (
<Ellipsis
size={theme.iconSize.md}
color={hovered || open ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
width={220}
testID="workspace-header-menu"
>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={
<SquareTerminal
size={16}
color={theme.colors.foregroundMuted}
/>
}
disabled={createTerminalMutation.isPending}
onSelect={handleCreateTerminal}
>
New terminal
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={
<Copy
size={16}
color={theme.colors.foregroundMuted}
/>
}
disabled={!normalizedWorkspaceId.startsWith("/")}
onSelect={handleCopyWorkspacePath}
>
Copy workspace path
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
</>
}
right={
<View style={styles.headerRight}>
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={styles.headerActionButton}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
>
<MoreVertical
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
{!isMobile && isGitCheckout ? (
<>
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={normalizedWorkspaceId}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
width={220}
testID="workspace-header-menu"
>
<DropdownMenuItem
testID="workspace-header-copy-path"
leading={
<Copy
size={16}
color={theme.colors.foregroundMuted}
/>
}
disabled={!normalizedWorkspaceId.startsWith("/")}
onSelect={handleCopyWorkspacePath}
<Pressable
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
style={({ hovered, pressed }) => [
styles.sourceControlButton,
workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
(hovered || pressed || isExplorerOpen) && styles.sourceControlButtonHovered,
]}
>
Copy workspace path
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{isMobile ? (
isGitCheckout ? (
<GitBranch
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : (
<Folder
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)
) : (
<PanelRight
size={theme.iconSize.md}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</HeaderToggleButton>
{({ hovered, pressed }) => {
const active = isExplorerOpen || hovered || pressed;
const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<SourceControlPanelIcon size={theme.iconSize.md} color={iconColor} />
{workspaceDescriptor?.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspaceDescriptor.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspaceDescriptor.diffStat.deletions}</Text>
</View>
) : null}
</>
);
}}
</Pressable>
</>
) : null}
{!isMobile && !isGitCheckout ? (
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{({ hovered }) => {
const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return <PanelRight size={theme.iconSize.md} color={color} />;
}}
</HeaderToggleButton>
) : null}
{isMobile ? (
<HeaderToggleButton
testID="workspace-explorer-toggle"
onPress={handleToggleExplorer}
tooltipLabel="Toggle explorer"
tooltipKeys={["mod", "E"]}
tooltipSide="left"
style={styles.headerActionButton}
accessible
accessibilityRole="button"
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
accessibilityState={{ expanded: isExplorerOpen }}
>
{({ hovered }) => {
const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
return isGitCheckout
? <GitBranch size={theme.iconSize.lg} color={color} />
: <Folder size={theme.iconSize.lg} color={color} />;
}}
</HeaderToggleButton>
) : null}
</View>
}
/>
@@ -1486,36 +1529,6 @@ function WorkspaceScreenContent({
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-terminal-tab"
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
onHoverIn={() => setIsNewTerminalHovered(true)}
onHoverOut={() => setIsNewTerminalHovered(false)}
disabled={createTerminalMutation.isPending}
accessibilityRole="button"
accessibilityLabel="New terminal tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
{createTerminalMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.terminalPlusIcon}>
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<View style={[styles.terminalPlusBadge, isNewTerminalHovered && styles.terminalPlusBadgeHovered]}>
<Plus size={10} color={theme.colors.foregroundMuted} />
</View>
</View>
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
</TooltipContent>
</Tooltip>
</View>
<Combobox
@@ -1568,10 +1581,6 @@ function WorkspaceScreenContent({
onCloseOtherTabs={handleCloseOtherTabs}
onSelectNewTabOption={handleSelectNewTabOption}
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
newTabTerminalOptionId={NEW_TAB_TERMINAL_OPTION_ID}
createTerminalPending={createTerminalMutation.isPending}
isNewTerminalHovered={isNewTerminalHovered}
setIsNewTerminalHovered={setIsNewTerminalHovered}
onReorderTabs={handleReorderTabs}
/>
)}
@@ -1654,13 +1663,49 @@ const styles = StyleSheet.create((theme) => ({
headerRight: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
gap: {
xs: theme.spacing[1],
md: theme.spacing[2],
},
},
headerActionButton: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
sourceControlButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[1],
paddingVertical: theme.spacing[1],
minHeight: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
minWidth: Math.ceil(theme.fontSize.sm * 1.5) + theme.spacing[1] * 2,
borderRadius: theme.borderRadius.md,
},
sourceControlButtonWithStats: {
paddingHorizontal: theme.spacing[3],
},
sourceControlButtonHovered: {
backgroundColor: theme.colors.surface2,
},
diffStatRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
flexShrink: 0,
},
diffStatAdditions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.green[400],
},
diffStatDeletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500],
},
newTabActions: {
flexDirection: "row",
alignItems: "center",
@@ -1679,34 +1724,10 @@ const styles = StyleSheet.create((theme) => ({
newTabActionButtonHovered: {
backgroundColor: theme.colors.surface2,
},
newTabActionButtonDisabled: {
opacity: 0.6,
},
newTabTooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
terminalPlusIcon: {
position: "relative",
width: theme.iconSize.sm,
height: theme.iconSize.sm,
alignItems: "center",
justifyContent: "center",
},
terminalPlusBadge: {
position: "absolute",
right: -5,
bottom: -5,
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
},
terminalPlusBadgeHovered: {
backgroundColor: theme.colors.surface2,
},
mobileTabsRow: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,

View File

@@ -16,8 +16,8 @@
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
"wrangler": "^4.59.1"
},

View File

@@ -109,9 +109,11 @@ function Home() {
{/* Content section */}
<div className="bg-black">
<main className="p-6 md:p-20 md:pt-8 max-w-5xl mx-auto">
<Features />
<CLISection />
<FAQ />
<div className="space-y-24">
<Features />
<CLISection />
<FAQ />
</div>
</main>
<footer className="p-6 md:p-20 md:pt-0 max-w-5xl mx-auto">
<div className="border-t border-white/10 pt-6">
@@ -231,17 +233,55 @@ function Differentiator({ title, description }: { title: string; description: st
function Features() {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div className="space-y-10">
{/* Primary differentiators - 2x2 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8">
{[
{ title: 'Self-hosted', description: 'The daemon runs on your laptop, home server, or VPS. Allowing you to take full advantage of your dev environment.' },
{ title: 'Multi-provider', description: 'Works with existing agent harnesses like Claude Code, Codex, and OpenCode from one interface.' },
{ title: 'Multi-host', description: 'Connect to multiple daemons and see all your agents in one place.' },
{ title: 'First-class voice', description: 'Real-time voice conversations and dictation. Talk to your agent, hear responses, and orchestrate work hands-free.' },
{ title: 'Optional relay', description: 'Use the hosted end-to-end encrypted relay for remote access, or connect directly over your network.' },
{ title: 'Cross-device', description: 'Jump seamlessly between iOS, Android, desktop, web, and CLI.' },
{ title: 'Git integration', description: 'Manage agents in isolated worktrees. Review diffs and ship directly from the app.' },
{ title: 'Open source', description: 'Free and open source. Run it yourself, fork it, contribute.' },
{
title: 'Self-hosted',
description: 'Agents run on your machine with your full dev environment. Use your tools, your configs and your skills.',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
),
},
{
title: 'Multi-provider',
description: 'Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 3h5v5" />
<path d="M8 3H3v5" />
<path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" />
<path d="m15 9 6-6" />
</svg>
),
},
{
title: 'Voice control',
description: 'Dictate tasks or talk through problems in voice mode. Hands-free when you need it.',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
),
},
{
title: 'Cross-device',
description: 'iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.',
icon: (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
),
},
].map((feature, i) => (
<motion.div
key={feature.title}
@@ -249,13 +289,31 @@ function Features() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-40px' }}
transition={{ duration: 0.4, delay: i * 0.06, ease: 'easeOut' }}
className="space-y-1"
className="space-y-2"
>
<p className="font-medium text-base">{feature.title}</p>
<p className="text-sm text-white/60">{feature.description}</p>
<div className="flex items-center gap-2">
<span className="text-white/40">{feature.icon}</span>
<p className="font-medium text-lg">{feature.title}</p>
</div>
<p className="text-sm text-white/60 leading-relaxed">{feature.description}</p>
</motion.div>
))}
</div>
{/* Also */}
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true, margin: '-40px' }}
transition={{ duration: 0.4, delay: 0.25, ease: 'easeOut' }}
className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-white/40"
>
<span>Multi-host</span>
<span>Built-in terminal</span>
<span>Git worktrees</span>
<span>E2E encrypted relay</span>
<span>Open source</span>
</motion.div>
</div>
)
}
@@ -277,7 +335,7 @@ function GetStarted() {
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/10 transition-colors"
>
<GlobeIcon className="h-4 w-4" />
Launch Web App
Web App
</a>
<a
href="https://apps.apple.com/app/paseo-pocket-engineer/id6758887924"
@@ -836,23 +894,12 @@ const cliExamples: CLIExample[] = [
title: 'Launch and monitor',
description: 'Give an agent a task and watch it work. The --worktree flag spins up an isolated git branch so you can run multiple agents on the same repo without conflicts.',
code:
`paseo run "implement user authentication"
paseo run --worktree feature-x "implement feature X"
`paseo run --provider claude/opus-4.6 "implement user authentication"
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task`,
},
{
title: 'Multi-provider',
description: 'Same commands, any agent. Pick a provider and model, or mix them in the same workflow.',
code:
`paseo run --provider claude/opus-4.6 "refactor the auth module"
paseo run --provider codex/gpt-5.4 "write integration tests"
# Use different models for different jobs
paseo run --detach --provider codex/gpt-5.4 "implement the feature" --name worker
paseo run --provider claude/opus-4.6 "review the worker branch for issues"`,
},
{
title: 'Orchestration',
@@ -923,7 +970,7 @@ function CLISection() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="pt-16 space-y-6"
className="space-y-6"
>
<div className="space-y-2">
<h2 className="text-2xl font-medium">CLI</h2>
@@ -973,22 +1020,59 @@ function FAQ() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="pt-16 space-y-6"
className="space-y-6"
>
<h2 className="text-2xl font-medium">FAQ</h2>
<div className="space-y-6">
<FAQItem question="Is this free?">
Paseo is free and open source. It wraps CLI tools like Claude Code and Codex, which you'll
need to have installed and configured with your own credentials. Voice is local-first by
default and can optionally use OpenAI speech providers if you configure them.
Yes. Paseo is free and open source. You need Claude Code, Codex, or OpenCode installed
with your own credentials. Voice is local-first by default and can optionally use OpenAI
speech providers if you configure them.
</FAQItem>
<FAQItem question="Does my code leave my machine?">
Paseo itself doesn't send your code anywhere. Agents run locally and communicate with
their own APIs as they normally would. We provide an optional end-to-end encrypted relay
for remote access, but you can also connect directly over your local network or use your
own tunnel.
Paseo doesn't send your code anywhere. Agents run locally and talk to their own APIs
as they normally would. For remote access, you can use the optional{' '}
<a href="/docs/security" className="underline hover:text-white/80">end-to-end encrypted relay</a>,
connect directly over your local network, or use your own tunnel.
</FAQItem>
<FAQItem question="What agents does it support?">
Claude Code, Codex, and OpenCode. Each agent runs as its own process using its own CLI.
Paseo doesn't modify or wrap their behavior.
</FAQItem>
<FAQItem question="Do I need the desktop app?">
No. You can run the daemon headless with{' '}
<code className="font-mono text-white/50">npm install -g @getpaseo/cli && paseo</code>{' '}
and use the CLI, web app, or mobile app to connect. The desktop app just bundles the
daemon with a UI.
</FAQItem>
<FAQItem question="How does voice work?">
Voice runs locally on your device by default. You talk, the app transcribes and sends it
to your agent as text. Optionally, you can configure OpenAI speech providers for
higher-quality transcription and text-to-speech.
See the <a href="/docs/voice" className="underline hover:text-white/80">voice docs</a>.
</FAQItem>
<FAQItem question="Can I connect from outside my network?">
Yes. You can use the hosted relay (end-to-end encrypted, Paseo can't read your traffic),
set up your own tunnel (Tailscale, Cloudflare Tunnel, etc.), or expose the daemon port directly.
See <a href="/docs/configuration" className="underline hover:text-white/80">configuration</a>.
</FAQItem>
<FAQItem question="Do I need git or GitHub?">
No. Paseo works in any directory. Worktrees are optional and only relevant if you use git.
You can run agents anywhere you'd normally work.
</FAQItem>
<FAQItem question="Can I get banned for using Paseo?">
<p>We can't make promises on behalf of providers.</p>
<p>That said, Paseo launches the official first-party CLIs (Claude Code, Codex, OpenCode) as
subprocesses. It doesn't extract tokens or call inference APIs directly. From the provider's
perspective, usage through Paseo is indistinguishable from running the CLI yourself.</p>
<p>I've been using Paseo with all providers for months without issue.</p>
</FAQItem>
<FAQItem question="How do worktrees work?">
When you launch an agent with the worktree option (from the app, desktop, or CLI), Paseo
creates a git worktree and runs the agent inside it. The agent works on an isolated branch
without touching your main working directory. See the{' '}
<a href="/docs/worktrees" className="underline hover:text-white/80">worktrees docs</a>.
</FAQItem>
<FAQItem question="What agents does it support?">Claude Code, Codex, and OpenCode.</FAQItem>
</div>
</motion.div>
)