diff --git a/package-lock.json b/package-lock.json
index bbbd4552d..cff187566 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
}
}
}
diff --git a/package.json b/package.json
index 0d9030c0a..61cbe3905 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/packages/app/metro.config.js b/packages/app/metro.config.js
index e4dc931a9..a4ac59f3c 100644
--- a/packages/app/metro.config.js
+++ b/packages/app/metro.config.js
@@ -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("./") ||
diff --git a/packages/app/package.json b/packages/app/package.json
index cd823f0f3..acfa30647 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -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",
diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx
index a9d4fada6..45fe75571 100644
--- a/packages/app/src/app/index.tsx
+++ b/packages/app/src/app/index.tsx
@@ -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 (
-
-
-
- );
+ return ;
}
if (!targetServerId) {
- return ;
+ if (isReconciling) {
+ return ;
+ }
+ return ;
}
return null;
diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx
index 7944294a5..0516b9a45 100644
--- a/packages/app/src/components/explorer-sidebar.tsx
+++ b/packages/app/src/components/explorer-sidebar.tsx
@@ -412,6 +412,7 @@ function SidebarContent({
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
+ hideHeaderRow={!isMobile}
/>
)}
{resolvedTab === "files" && (
diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/components/git-actions-split-button.tsx
new file mode 100644
index 000000000..c4aad1bff
--- /dev/null
+++ b/packages/app/src/components/git-actions-split-button.tsx
@@ -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 (
+
+ {gitActions.primary ? (
+
+ [
+ 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" ? (
+
+ ) : (
+
+ {gitActions.primary.icon}
+ {getActionDisplayLabel(gitActions.primary)}
+
+ )}
+
+ {gitActions.secondary.length > 0 ? (
+
+ [
+ styles.splitButtonCaret,
+ (hovered || pressed || open) && styles.splitButtonCaretHovered,
+ ]}
+ accessibilityRole="button"
+ accessibilityLabel="More options"
+ >
+
+
+
+ {gitActions.secondary.map((action, index) => {
+ const needsSeparator = action.id === "merge-from-base" || action.id === "push";
+ return (
+
+ {needsSeparator && index > 0 ? : null}
+
+ {action.label}
+
+
+ );
+ })}
+
+
+ ) : null}
+
+ ) : null}
+ {gitActions.menu.length > 0 ? (
+
+
+
+
+
+ {gitActions.menu.map((action) => (
+
+ {action.label}
+
+ ))}
+
+
+ ) : null}
+
+ );
+}
+
+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],
+ },
+}));
diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx
index d03a7f8b8..fb0d49f19 100644
--- a/packages/app/src/components/git-diff-pane.tsx
+++ b/packages/app/src/components/git-diff-pane.tsx
@@ -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 (
-
-
-
-
- {branchLabel}
-
-
- {isGit ? (
-
- {gitActions.primary ? (
-
-
- {gitActions.primary.status === "pending" ? (
-
- ) : (
-
- {gitActions.primary.icon}
- {getActionDisplayLabel(gitActions.primary)}
-
- )}
-
- {gitActions.secondary.length > 0 ? (
-
-
-
-
-
- {gitActions.secondary.map((action, index) => {
- const needsSeparator = action.id === "merge-from-base" || action.id === "push";
- return (
-
- {needsSeparator && index > 0 ? : null}
-
- {action.label}
-
-
- );
- })}
-
-
- ) : null}
-
- ) : null}
- {gitActions.menu.length > 0 ? (
-
-
-
-
-
- {gitActions.menu.map((action) => (
-
- {action.label}
-
- ))}
-
-
- ) : null}
+ {!hideHeaderRow ? (
+
+
+
+
+ {branchLabel}
+
- ) : null}
-
+ {isGit ? (
+
+ ) : null}
+
+ ) : null}
{isGit ? (
@@ -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],
diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx
index cf9968de4..c6a8f1c66 100644
--- a/packages/app/src/components/headers/header-toggle-button.tsx
+++ b/packages/app/src/components/headers/header-toggle-button.tsx
@@ -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 {
+ onPress: NonNullable;
+ tooltipLabel: string;
+ tooltipKeys: ShortcutKey[];
+ tooltipSide: "left" | "right" | "top" | "bottom";
+ tooltipDelayDuration?: number;
+ style?: StyleProp;
+ children: ReactNode | ((state: HeaderToggleButtonState) => ReactNode);
+}
+
export function HeaderToggleButton({
onPress,
tooltipLabel,
@@ -22,16 +37,7 @@ export function HeaderToggleButton({
disabled,
children,
...props
-}: PropsWithChildren<
- Omit & {
- onPress: NonNullable;
- tooltipLabel: string;
- tooltipKeys: ShortcutKey[];
- tooltipSide: "left" | "right" | "top" | "bottom";
- tooltipDelayDuration?: number;
- style?: StyleProp;
- }
->): 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}
diff --git a/packages/app/src/components/icons/source-control-panel-icon.tsx b/packages/app/src/components/icons/source-control-panel-icon.tsx
new file mode 100644
index 000000000..ad573925d
--- /dev/null
+++ b/packages/app/src/components/icons/source-control-panel-icon.tsx
@@ -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 (
+
+ );
+}
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 06c7607e1..56e45329d 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -339,7 +339,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
hovered && styles.newAgentButtonTextHovered,
]}
>
- Open project
+ Add project
>
)}
@@ -463,7 +463,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
- Open project
+ Add project
>
)}
diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx
index 039a76822..13463b991 100644
--- a/packages/app/src/components/message-input.tsx
+++ b/packages/app/src/components/message-input.tsx
@@ -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
}
+function logWebStickyBottom(
+ event: string,
+ details: Record
+): 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(funct
const toast = useToast()
const voice = useVoiceOptional()
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
+ const rootRef = useRef(null)
+ const inputWrapperRef = useRef(null)
const textInputRef = useRef unknown }) | null>(
null
)
@@ -509,6 +568,12 @@ export const MessageInput = forwardRef(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(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(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
) {
+ 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) {
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(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 (
-
+
{/* Regular input */}
-
+
{/* Image preview pills */}
{hasImages && (
diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx
index 591eec5a2..68ba21f87 100644
--- a/packages/app/src/components/project-picker-modal.tsx
+++ b/packages/app/src/components/project-picker-modal.tsx
@@ -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 (
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index 2ae51e0b7..ed794509d 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -665,11 +665,6 @@ function WorkspaceRowInner({
- {showShortcutBadge && shortcutNumber !== null ? (
-
- {shortcutNumber}
-
- ) : null}
{onArchive && (isHovered || isMobile) ? (
-{workspace.diffStat.deletions}
) : null}
+ {showShortcutBadge && shortcutNumber !== null ? (
+
+ {shortcutNumber}
+
+ ) : null}
@@ -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,
},
}))
diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx
index 2a30721d1..21d360bfb 100644
--- a/packages/app/src/components/stream-strategy-web.tsx
+++ b/packages/app/src/components/stream-strategy-web.tsx
@@ -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): 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,
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(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) => {
+ 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 => ({
diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx
index 9649b89da..13e7eb5b4 100644
--- a/packages/app/src/contexts/daemon-registry-context.tsx
+++ b/packages/app/src/contexts/daemon-registry-context.tsx
@@ -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,
diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/hooks/use-git-actions.ts
new file mode 100644
index 000000000..b3ee1e4b2
--- /dev/null
+++ b/packages/app/src/hooks/use-git-actions.ts
@@ -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(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();
+
+ 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 };
+}
diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx
index 585288a68..65abda30f 100644
--- a/packages/app/src/screens/agent/agent-ready-screen.tsx
+++ b/packages/app/src/screens/agent/agent-ready-screen.tsx
@@ -74,6 +74,13 @@ function logAgentExplorer(event: string, details: Record): void
console.log(`[AgentExplorer] ${event}`, details);
}
+function logWebStickyBottom(event: string, details: Record): 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");
+ }}
/>
)}
diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx
index 468e869ea..91208fe53 100644
--- a/packages/app/src/screens/open-project-screen.tsx
+++ b/packages/app/src/screens/open-project-screen.tsx
@@ -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 (
+
+
+
What shall we build today?
@@ -24,7 +41,7 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
testID="open-project-submit"
>
- Open a project
+ Add a project
@@ -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",
diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx
new file mode 100644
index 000000000..4cc39b4a5
--- /dev/null
+++ b/packages/app/src/screens/startup-splash-screen.tsx
@@ -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 (
+
+
+ Starting up…
+
+ );
+}
diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
index 5b1596af4..58f54d835 100644
--- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
+++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx
@@ -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;
onSelectNewTabOption: (optionId: NewTabOptionId) => void;
newTabAgentOptionId: NewTabOptionId;
- newTabTerminalOptionId: NewTabOptionId;
- createTerminalPending: boolean;
- isNewTerminalHovered: boolean;
- setIsNewTerminalHovered: Dispatch>;
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({
New agent tab
-
- 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 ? (
-
- ) : (
-
-
-
-
-
-
- )}
-
-
- New terminal tab
-
-
);
@@ -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,
diff --git a/packages/app/src/screens/workspace/workspace-git-actions.tsx b/packages/app/src/screens/workspace/workspace-git-actions.tsx
new file mode 100644
index 000000000..33b0f8eba
--- /dev/null
+++ b/packages/app/src/screens/workspace/workspace-git-actions.tsx
@@ -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: ,
+ push: ,
+ viewPr: ,
+ createPr: ,
+ merge: ,
+ mergeFromBase: ,
+ archive: ,
+ }), [theme.colors.foregroundMuted]);
+
+ const { gitActions, isGit } = useGitActions({ serverId, cwd, icons });
+
+ if (!isGit) {
+ return null;
+ }
+
+ return ;
+}
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index b43066724..9563d0e01 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -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["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(null);
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState(
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({
>
)}
+
+
+ {({ hovered, open }) => (
+
+ )}
+
+
+
+ }
+ disabled={createTerminalMutation.isPending}
+ onSelect={handleCreateTerminal}
+ >
+ New terminal
+
+
+ }
+ disabled={!normalizedWorkspaceId.startsWith("/")}
+ onSelect={handleCopyWorkspacePath}
+ >
+ Copy workspace path
+
+
+
>
}
right={
-
-
-
+
-
-
-
- }
- disabled={!normalizedWorkspaceId.startsWith("/")}
- onSelect={handleCopyWorkspacePath}
+ [
+ styles.sourceControlButton,
+ workspaceDescriptor?.diffStat && styles.sourceControlButtonWithStats,
+ (hovered || pressed || isExplorerOpen) && styles.sourceControlButtonHovered,
+ ]}
>
- Copy workspace path
-
-
-
-
- {isMobile ? (
- isGitCheckout ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
+ {({ hovered, pressed }) => {
+ const active = isExplorerOpen || hovered || pressed;
+ const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted;
+ return (
+ <>
+
+ {workspaceDescriptor?.diffStat ? (
+
+ +{workspaceDescriptor.diffStat.additions}
+ -{workspaceDescriptor.diffStat.deletions}
+
+ ) : null}
+ >
+ );
+ }}
+
+ >
+ ) : null}
+ {!isMobile && !isGitCheckout ? (
+
+ {({ hovered }) => {
+ const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
+ return ;
+ }}
+
+ ) : null}
+ {isMobile ? (
+
+ {({ hovered }) => {
+ const color = isExplorerOpen || hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
+ return isGitCheckout
+ ?
+ : ;
+ }}
+
+ ) : null}
}
/>
@@ -1486,36 +1529,6 @@ function WorkspaceScreenContent({
-
- 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 ? (
-
- ) : (
-
-
-
-
-
-
- )}
-
-
- New terminal tab
-
-
)}
@@ -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,
diff --git a/packages/website/package.json b/packages/website/package.json
index 1cb692fc9..bc0d4d622 100644
--- a/packages/website/package.json
+++ b/packages/website/package.json
@@ -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"
},
diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx
index 9b224debc..eba8725ee 100644
--- a/packages/website/src/routes/index.tsx
+++ b/packages/website/src/routes/index.tsx
@@ -109,9 +109,11 @@ function Home() {
{/* Content section */}