From aa1d33f0a05e3cf29632d6d3ddc573e3972ca1b9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 11 Apr 2026 13:09:51 +0700 Subject: [PATCH] chore: checkpoint current worktree state --- docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md | 158 ++++++++++ .../src/components/workspace-hover-card.tsx | 279 +----------------- .../app/src/screens/startup-splash-screen.tsx | 1 + packages/desktop/scripts/dev.sh | 2 +- packages/desktop/src/daemon/daemon-manager.ts | 70 +++-- packages/desktop/src/daemon/runtime-paths.ts | 35 ++- packages/server/package.json | 2 +- 7 files changed, 246 insertions(+), 301 deletions(-) create mode 100644 docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md diff --git a/docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md b/docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md new file mode 100644 index 000000000..4a3f07554 --- /dev/null +++ b/docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md @@ -0,0 +1,158 @@ +# Attachment-Based Review Context Plan + +## Goal + +Use structured attachments as the source of truth for review context during agent creation. + +This covers two related behaviors: + +1. Provider-facing context for the agent prompt +2. Worktree checkout behavior when creating a worktree from a review item + +The design should stay compatible with future forge support such as GitLab. + +## Core Direction + +- Do not parse the prompt to discover issue/PR intent. +- Do not introduce deeply nested transport objects for this flow. +- Make the attachment itself the discriminated union. +- Keep forge-specific checkout logic below the attachment layer. +- Let each agent provider translate attachments into its own prompt/input format. + +## Initial Attachment Types + +Two initial structured attachment types: + +- `github_pr` +- `github_issue` + +With separate MIME types: + +- `application/github-pr` +- `application/github-issue` + +Proposed wire shape: + +```ts +type AgentAttachment = + | { + type: "github_pr"; + mimeType: "application/github-pr"; + number: number; + title: string; + url: string; + body?: string | null; + baseRefName?: string | null; + headRefName?: string | null; + } + | { + type: "github_issue"; + mimeType: "application/github-issue"; + number: number; + title: string; + url: string; + body?: string | null; + }; +``` + +## Backward Compatibility + +- Add new optional `attachments` fields; keep existing `images` fields. +- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request. +- Malformed attachment entries should also be ignored safely. +- Old clients continue working because `attachments` is optional. +- New clients can send `attachments` without breaking older flows that still rely on `images`. + +## Request-Level Changes + +Add optional `attachments` to: + +- `create_agent_request` +- `send_agent_message_request` + +Existing `initialPrompt` remains plain user text. + +The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth. + +## App Responsibilities + +When the user selects a GitHub item: + +- If it is a PR, create a `github_pr` attachment +- If it is an issue, create a `github_issue` attachment +- Include the attachment in the create-agent request + +The UI can still render friendly labels and previews from the same data. + +The app should stop treating PR/issue context as prompt-only metadata. + +## Worktree Creation Behavior + +During agent creation, if worktree creation is requested: + +- Inspect normalized attachments +- If a `github_pr` attachment is present, use it to drive checkout for the worktree + +The important rule: + +- attachment type identifies the review object +- server-side git logic decides how to check it out + +This keeps the attachment contract simple while allowing fork-safe implementation details. + +## Checkout Resolution + +The attachment itself should not encode the full checkout strategy. + +Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic. + +Examples of possible implementation strategies: + +- `gh pr checkout` +- `git fetch origin refs/pull//head:` +- another GitHub-aware resolver if needed later + +This is intentionally a server implementation detail, not part of the attachment schema. + +## Provider Responsibilities + +Each provider adapter should receive normalized attachments and decide how to represent them for the model. + +Examples: + +- Claude: render attachment into text blocks +- Codex: inject as blocks or text +- OpenCode: send as file/resource-like input where appropriate +- ACP: convert to resource/text forms as supported + +The session layer should not hardcode one translation strategy for all providers. + +## Suggested Implementation Order + +1. Add tolerant attachment schema and normalization in shared/server message handling. +2. Thread `attachments` through app -> daemon client -> session layer. +3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection. +4. Teach worktree creation to inspect attachments and special-case `github_pr`. +5. Update provider adapters to translate attachments into provider-specific prompt/input forms. +6. Optionally migrate image transport into the same attachment mechanism later. + +## Files Likely Involved + +- `packages/server/src/shared/messages.ts` +- `packages/server/src/client/daemon-client.ts` +- `packages/server/src/server/session.ts` +- `packages/server/src/server/worktree-session.ts` +- `packages/server/src/server/agent/agent-sdk-types.ts` +- `packages/server/src/server/agent/providers/claude-agent.ts` +- `packages/server/src/server/agent/providers/codex-app-server-agent.ts` +- `packages/server/src/server/agent/providers/opencode-agent.ts` +- `packages/server/src/server/agent/providers/acp-agent.ts` +- `packages/app/src/screens/new-workspace-screen.tsx` +- `packages/app/src/screens/agent/draft-agent-screen.tsx` +- `packages/app/src/contexts/session-context.tsx` + +## Notes + +- The current image path can remain in place initially. +- The plan intentionally keeps checkout concerns separate from provider prompt translation. +- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields. diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index a0c5298d0..fef2f586c 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -6,11 +6,10 @@ import { type PropsWithChildren, type ReactElement, } from "react"; -import { useMutation } from "@tanstack/react-query"; import { Dimensions, Platform, Text, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ExternalLink, Globe, LoaderCircle, Play, SquareTerminal } from "lucide-react-native"; +import { ExternalLink } from "lucide-react-native"; import { GitHubIcon } from "@/components/icons/github-icon"; import { DiffStat } from "@/components/diff-stat"; import { Pressable } from "react-native"; @@ -18,10 +17,7 @@ import { Portal } from "@gorhom/portal"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { PrHint } from "@/hooks/use-checkout-pr-status-query"; -import { useToast } from "@/contexts/toast-context"; -import { useSessionStore } from "@/stores/session-store"; import { openExternalUrl } from "@/utils/open-external-url"; -import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import { PrBadge } from "@/components/sidebar-workspace-list"; interface Rect { @@ -108,8 +104,7 @@ function WorkspaceHoverCardDesktop({ const triggerHoveredRef = useRef(false); const contentHoveredRef = useRef(false); - const hasScripts = workspace.scripts.length > 0; - const hasContent = hasScripts || prHint !== null; + const hasContent = prHint !== null || !!workspace.diffStat; const clearGraceTimer = useCallback(() => { if (graceTimerRef.current) { @@ -195,32 +190,6 @@ function WorkspaceHoverCardDesktop({ ); } -function getScriptHealthColor(input: { - health: SidebarWorkspaceEntry["scripts"][number]["health"]; - theme: ReturnType["theme"]; -}): string { - if (input.health === "healthy") { - return input.theme.colors.palette.blue[500]; - } - if (input.health === "unhealthy") { - return input.theme.colors.palette.red[500]; - } - return input.theme.colors.foregroundMuted; -} - -function getScriptHealthLabel( - health: SidebarWorkspaceEntry["scripts"][number]["health"], -): "Healthy" | "Unhealthy" | "Unknown" { - if (health === "healthy") { - return "Healthy"; - } - if (health === "unhealthy") { - return "Unhealthy"; - } - return "Unknown"; -} - - function WorkspaceHoverCardContent({ workspace, prHint, @@ -235,39 +204,10 @@ function WorkspaceHoverCardContent({ onContentLeave: () => void; }): ReactElement | null { const { theme } = useUnistyles(); - const toast = useToast(); - const client = useSessionStore((state) => state.sessions[workspace.serverId]?.client ?? null); const bottomSheetInternal = useBottomSheetModalInternal(true); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); - const startScriptMutation = useMutation({ - mutationFn: async (scriptName: string) => { - if (!client) { - throw new Error("Daemon client not available"); - } - const result = await client.startWorkspaceScript(workspace.workspaceId, scriptName); - if (result.error) { - throw new Error(result.error); - } - return result; - }, - onSuccess: (data) => { - if (data.terminalId) { - navigateToPreparedWorkspaceTab({ - serverId: workspace.serverId, - workspaceId: workspace.workspaceId, - target: { kind: "terminal", terminalId: data.terminalId }, - }); - } - }, - onError: (error, scriptName) => { - toast.show( - error instanceof Error ? error.message : `Failed to start ${scriptName}`, - { variant: "error" }, - ); - }, - }); // Measure trigger — same pattern as tooltip.tsx useEffect(() => { @@ -345,167 +285,6 @@ function WorkspaceHoverCardContent({ {prHint ? : null} ) : null} - {workspace.scripts.length > 0 ? ( - <> - - - - Scripts - - - {workspace.scripts.map((script) => { - const isRunning = script.lifecycle === "running"; - const isService = (script.type ?? "service") === "service"; - const isLinkable = isService && isRunning && !!script.url; - const exitCode = script.exitCode ?? null; - - let dotColor: string; - if (isService) { - dotColor = isRunning - ? getScriptHealthColor({ health: script.health, theme }) - : theme.colors.foregroundMuted; - } else if (isRunning) { - dotColor = theme.colors.palette.blue[500]; - } else if (exitCode === 0) { - dotColor = theme.colors.palette.green[500]; - } else if (exitCode !== null) { - dotColor = theme.colors.palette.red[500]; - } else { - dotColor = theme.colors.foregroundMuted; - } - - let accessibilityStatus: string; - if (isService) { - accessibilityStatus = isRunning ? getScriptHealthLabel(script.health) : "Stopped"; - } else if (isRunning) { - accessibilityStatus = "Running"; - } else if (exitCode === 0) { - accessibilityStatus = "Completed"; - } else if (exitCode !== null) { - accessibilityStatus = `Failed (exit ${exitCode})`; - } else { - accessibilityStatus = "Stopped"; - } - - return ( - [ - styles.listRow, - hovered && isLinkable && styles.listRowHovered, - ]} - onPress={isLinkable ? () => void openExternalUrl(script.url!) : undefined} - disabled={!isLinkable} - > - {({ hovered }) => ( - <> - {isService ? ( - - ) : ( - - )} - - {script.scriptName} - - {isService && isRunning && script.url ? ( - - {script.url.replace(/^https?:\/\//, "")} - - ) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? ( - - exit {exitCode} - - ) : ( - - )} - {isRunning ? ( - isLinkable && hovered ? ( - - - - ) : null - ) : ( - { - event.stopPropagation(); - startScriptMutation.mutate(script.scriptName); - }} - style={styles.startButton} - > - {({ hovered: actionHovered }) => - startScriptMutation.isPending && - startScriptMutation.variables === script.scriptName ? ( - - ) : ( - <> - - - Run - - - ) - } - - )} - - )} - - ); - })} - - - ) : null} {prHint?.checks && prHint.checks.length > 0 ? ( <> @@ -619,30 +398,6 @@ const styles = StyleSheet.create((theme) => ({ height: 1, backgroundColor: theme.colors.border, }, - sectionLabelRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1.5], - paddingHorizontal: theme.spacing[3], - paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[1], - }, - sectionLabel: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - color: theme.colors.foregroundMuted, - }, - sectionList: { - paddingBottom: theme.spacing[1], - }, - listRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: 6, - minHeight: 28, - }, listRowHovered: { backgroundColor: theme.colors.surface2, }, @@ -656,27 +411,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, - listRowLabel: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - lineHeight: 18, - flexShrink: 0, - }, - listRowSecondary: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - lineHeight: 18, - flex: 1, - minWidth: 0, - textAlign: "right", - }, - listRowSpacer: { - flex: 1, - minWidth: 0, - }, - scriptIcon: { - flexShrink: 0, - }, checksSummaryRow: { flexDirection: "row", alignItems: "center", @@ -706,13 +440,4 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, - startButton: { - flexDirection: "row", - alignItems: "center", - gap: 3, - }, - startButtonLabel: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - }, })); diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index f70ce792c..d5358c98b 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -49,6 +49,7 @@ const styles = StyleSheet.create((theme) => ({ ? { overflowX: "auto", overflowY: "auto", + WebkitAppRegion: "no-drag", } : null), }, diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index a4756af9a..9181c3c11 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -20,7 +20,7 @@ echo " Metro: http://localhost:${EXPO_PORT}" echo "══════════════════════════════════════════════════════" # Launch Metro + Electron together, kill both on exit -"$ROOT_DIR/node_modules/.bin/concurrently" \ +exec "$ROOT_DIR/node_modules/.bin/concurrently" \ --kill-others \ --names "metro,electron" \ --prefix-colors "magenta,cyan" \ diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index af9942456..b642db159 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -209,6 +209,8 @@ async function resolveStatus(): Promise { error: null, }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logDesktopDaemonLifecycle("resolveStatus CLI command failed", { error: errorMessage }); return { serverId: "", status: "stopped", @@ -218,7 +220,7 @@ async function resolveStatus(): Promise { home, version: null, desktopManaged: false, - error: error instanceof Error ? error.message : String(error), + error: errorMessage, }; } } @@ -231,6 +233,14 @@ function normalizeVersion(version: string | null): string | null { async function startDaemon(): Promise { const current = await resolveStatus(); + logDesktopDaemonLifecycle("initial status check before start", { + status: current.status, + pid: current.pid, + listen: current.listen, + serverId: current.serverId || null, + error: current.error, + desktopManaged: current.desktopManaged, + }); if (current.status === "running") { const appVersion = normalizeVersion(resolveDesktopAppVersion()); const daemonVersion = normalizeVersion(current.version); @@ -269,7 +279,16 @@ async function startDaemon(): Promise { const child: ChildProcess = spawnProcess(invocation.command, invocation.args, { detached: true, env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" }, - stdio: ["ignore", "ignore", "ignore"], + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout!.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + child.stderr!.on("data", (data: Buffer) => { + stderr += data.toString(); }); logDesktopDaemonLifecycle("detached spawn returned", { @@ -280,41 +299,54 @@ async function startDaemon(): Promise { child.unref(); - // Wait for process to survive the grace period - const exitedEarly = await new Promise((resolve) => { + type GraceResult = + | { exitedEarly: false } + | { exitedEarly: true; code: number | null; signal: string | null; error?: Error }; + + const result = await new Promise((resolve) => { let settled = false; - const finish = (value: boolean) => { + const finish = (value: GraceResult) => { if (settled) return; settled = true; resolve(value); }; - const timer = setTimeout(() => finish(false), DETACHED_STARTUP_GRACE_MS); + const timer = setTimeout(() => finish({ exitedEarly: false }), DETACHED_STARTUP_GRACE_MS); - child.once("error", () => { - logDesktopDaemonLifecycle("detached child emitted error during grace period", { - childPid: child.pid ?? null, - }); + child.once("error", (error) => { clearTimeout(timer); - finish(true); + finish({ exitedEarly: true, code: null, signal: null, error }); }); - child.once("exit", () => { - logDesktopDaemonLifecycle("detached child emitted exit during grace period", { - childPid: child.pid ?? null, - }); + child.once("exit", (code, signal) => { clearTimeout(timer); - finish(true); + finish({ exitedEarly: true, code, signal }); }); }); logDesktopDaemonLifecycle("detached startup grace period completed", { childPid: child.pid ?? null, - exitedEarly, + exitedEarly: result.exitedEarly, + ...(result.exitedEarly + ? { + exitCode: result.code, + signal: result.signal, + error: result.error?.message ?? null, + stdout: stdout.slice(0, 2000), + stderr: stderr.slice(0, 2000), + } + : {}), }); - if (exitedEarly) { + if (result.exitedEarly) { + const reason = result.error + ? result.error.message + : `exit code ${result.code ?? "unknown"}${result.signal ? ` (${result.signal})` : ""}`; + const parts = [`Daemon failed to start: ${reason}`]; + if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`); + if (stdout.trim()) parts.push(`stdout:\n${stdout.trim()}`); const logs = tailFile(logFilePath(), 15); - throw new Error(`Daemon failed to start.${logs ? `\n\nRecent logs:\n${logs}` : ""}`); + if (logs) parts.push(`Recent logs (${logFilePath()}):\n${logs}`); + throw new Error(parts.join("\n\n")); } // Poll for PID file with server ID diff --git a/packages/desktop/src/daemon/runtime-paths.ts b/packages/desktop/src/daemon/runtime-paths.ts index 4245ce1b9..0399d27e7 100644 --- a/packages/desktop/src/daemon/runtime-paths.ts +++ b/packages/desktop/src/daemon/runtime-paths.ts @@ -4,6 +4,7 @@ import { spawnProcess } from "@getpaseo/server"; import { createRequire } from "node:module"; import path from "node:path"; import { app } from "electron"; +import log from "electron-log/main"; import { DESKTOP_CLI_ENV, createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation, @@ -284,7 +285,18 @@ export async function runCliTextCommand(args: string[]): Promise { if (result.exitCode !== 0) { const stderr = result.stderr.trim(); - throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`); + const stdout = result.stdout.trim(); + log.warn("[desktop cli]", `CLI text command failed`, { + args, + exitCode: result.exitCode, + stdout: stdout.slice(0, 500), + stderr: stderr.slice(0, 500), + }); + throw new Error( + stderr.length > 0 + ? stderr + : `CLI command failed with exit code ${result.exitCode}${stdout.length > 0 ? `\nstdout: ${stdout.slice(0, 200)}` : ""}`, + ); } return result.stdout.trimEnd(); @@ -298,11 +310,24 @@ export async function runCliJsonCommand(args: string[]): Promise { if (result.exitCode !== 0) { const stderr = result.stderr.trim(); - throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`); + const stdout = result.stdout.trim(); + log.warn("[desktop cli]", `CLI command failed`, { + args, + exitCode: result.exitCode, + stdout: stdout.slice(0, 500), + stderr: stderr.slice(0, 500), + command: invocation.command, + }); + throw new Error( + stderr.length > 0 + ? stderr + : `CLI command failed with exit code ${result.exitCode}${stdout.length > 0 ? `\nstdout: ${stdout.slice(0, 200)}` : ""}`, + ); } const stdout = result.stdout.trim(); if (stdout.length === 0) { + log.warn("[desktop cli]", `CLI command produced no output`, { args }); throw new Error("CLI command did not produce JSON output."); } @@ -310,7 +335,11 @@ export async function runCliJsonCommand(args: string[]): Promise { // Extract the first valid JSON object or array from the output. const jsonStart = stdout.search(/[{[]/); if (jsonStart < 0) { - throw new Error("CLI command output contained no JSON."); + log.warn("[desktop cli]", `CLI command output contained no JSON`, { + args, + stdout: stdout.slice(0, 500), + }); + throw new Error(`CLI command output contained no JSON. Output: ${stdout.slice(0, 200)}`); } const jsonText = stdout.slice(jsonStart); diff --git a/packages/server/package.json b/packages/server/package.json index d69d7941d..f9b566d30 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -31,7 +31,7 @@ "dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts", "dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.cpSync('src/server/db/migrations','dist/server/server/db/migrations',{recursive:true});\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build", "start": "cross-env NODE_ENV=production node dist/server/server/index.js",