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 ( + + + {/* Plus */} + + + {/* Minus */} + + + ); +} 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 */}
- - - +
+ + + +