From 3681fd132b91fe383f079ca82f4ce76d1036e120 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 18:34:24 +0800 Subject: [PATCH 1/4] Open workspace files in desktop targets --- packages/app/e2e/helpers/desktop-updates.ts | 51 +- .../app/e2e/workspace-open-in-editor.spec.ts | 36 +- .../src/components/icons/editor-app-icons.tsx | 17 +- packages/app/src/desktop/host.ts | 19 + packages/app/src/git/github-url.test.ts | 81 +++- packages/app/src/git/github-url.ts | 47 ++ .../src/hooks/use-preferred-editor.test.ts | 6 + .../app/src/hooks/use-preferred-editor.ts | 5 +- .../workspace-open-in-editor-button.tsx | 146 +++--- .../workspace/workspace-open-targets.test.ts | 36 -- .../workspace/workspace-open-targets.ts | 13 - .../screens/workspace/workspace-screen.tsx | 45 +- .../app/src/workspace/desktop-open-targets.ts | 71 +++ .../app/src/workspace/editor-targets.test.ts | 11 + packages/app/src/workspace/editor-targets.ts | 15 + .../app/src/workspace/file-open/index.test.ts | 91 ++++ packages/app/src/workspace/file-open/index.ts | 102 ++++ .../src/workspace/open-target-planner.test.ts | 150 ++++++ .../app/src/workspace/open-target-planner.ts | 146 ++++++ packages/client/src/daemon-client.ts | 34 -- .../src/features/editor-targets.test.ts | 459 ++++++++++++++++++ .../desktop/src/features/editor-targets.ts | 353 ++++++++++++++ packages/desktop/src/main.ts | 2 + packages/desktop/src/preload.ts | 9 + packages/protocol/src/messages.ts | 80 ++- .../protocol/src/messages.workspaces.test.ts | 60 +-- .../server/src/server/editor-targets.test.ts | 118 ----- packages/server/src/server/editor-targets.ts | 168 ------- packages/server/src/server/session.ts | 128 +---- .../src/server/session.workspaces.test.ts | 153 +----- 30 files changed, 1830 insertions(+), 822 deletions(-) delete mode 100644 packages/app/src/screens/workspace/workspace-open-targets.test.ts delete mode 100644 packages/app/src/screens/workspace/workspace-open-targets.ts create mode 100644 packages/app/src/workspace/desktop-open-targets.ts create mode 100644 packages/app/src/workspace/editor-targets.test.ts create mode 100644 packages/app/src/workspace/editor-targets.ts create mode 100644 packages/app/src/workspace/open-target-planner.test.ts create mode 100644 packages/app/src/workspace/open-target-planner.ts create mode 100644 packages/desktop/src/features/editor-targets.test.ts create mode 100644 packages/desktop/src/features/editor-targets.ts delete mode 100644 packages/server/src/server/editor-targets.test.ts delete mode 100644 packages/server/src/server/editor-targets.ts diff --git a/packages/app/e2e/helpers/desktop-updates.ts b/packages/app/e2e/helpers/desktop-updates.ts index 9fac2a086..c6c728fca 100644 --- a/packages/app/e2e/helpers/desktop-updates.ts +++ b/packages/app/e2e/helpers/desktop-updates.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { appendFile } from "node:fs/promises"; import { expect, type Page } from "@playwright/test"; import { openSettings } from "./app"; import { getE2EDaemonPort } from "./daemon-port"; @@ -64,6 +65,21 @@ export interface DesktopBridgeConfig { * false so tests that only assert copy don't inadvertently trigger state changes. */ confirmShouldAccept?: boolean; + editorTargets?: DesktopEditorTargetConfig[]; + editorRecordPath?: string; +} + +interface DesktopEditorTargetConfig { + id: string; + label: string; + kind: "editor" | "file-manager"; +} + +interface DesktopEditorOpenRecord { + editorId: string; + path: string; + cwd?: string; + mode?: "open" | "reveal"; } export interface ConfirmDialogCall { @@ -74,6 +90,7 @@ export interface ConfirmDialogCall { declare global { interface Window { __capturedDialogCall: ConfirmDialogCall | undefined; + __recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise; } } @@ -87,6 +104,15 @@ declare global { * can assert dialog copy without depending on window.confirm concatenation. */ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise { + if (config.editorRecordPath) { + await page.exposeFunction( + "__recordDesktopEditorOpen", + async (input: DesktopEditorOpenRecord) => { + await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8"); + }, + ); + } + await page.addInitScript((cfg) => { // Mutable state shared across IPC calls within this page let manageDaemon = cfg.manageBuiltInDaemon ?? false; @@ -108,7 +134,19 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi }; } - (window as unknown as { paseoDesktop: unknown }).paseoDesktop = { + const desktopBridge: { + platform: string; + invoke: (command: string, args?: Record) => Promise; + dialog: { + ask: (message: string, options?: Record) => Promise; + }; + getPendingOpenProject: () => Promise; + events: { on: () => Promise<() => void> }; + editor?: { + listTargets: () => Promise; + openTarget: (input: DesktopEditorOpenRecord) => Promise; + }; + } = { platform: "darwin", invoke: async (command: string, args?: Record) => { if (command === "check_app_update") { @@ -202,6 +240,17 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi getPendingOpenProject: async () => null, events: { on: async () => () => undefined }, }; + + if (cfg.editorTargets) { + desktopBridge.editor = { + listTargets: async () => cfg.editorTargets ?? [], + openTarget: async (input: DesktopEditorOpenRecord) => { + await window.__recordDesktopEditorOpen?.(input); + }, + }; + } + + (window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge; }, config); } diff --git a/packages/app/e2e/workspace-open-in-editor.spec.ts b/packages/app/e2e/workspace-open-in-editor.spec.ts index 3d37629a3..35d32a1d9 100644 --- a/packages/app/e2e/workspace-open-in-editor.spec.ts +++ b/packages/app/e2e/workspace-open-in-editor.spec.ts @@ -5,8 +5,10 @@ import { injectDesktopBridge } from "./helpers/desktop-updates"; import { clickSettingsBackToWorkspace } from "./helpers/settings"; interface EditorOpenRecord { - command: string; - args: string[]; + editorId: string; + path: string; + cwd?: string; + mode?: "open" | "reveal"; } function requireE2EEnv(name: string): string { @@ -43,8 +45,8 @@ async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise async function expectEditorOpened(input: { recordPath: string; - command: string; - workspacePath: string; + editorId: string; + path: string; afterCount: number; }): Promise { await expect @@ -53,10 +55,7 @@ async function expectEditorOpened(input: { const records = await readEditorOpenRecords(input.recordPath); return records .slice(input.afterCount) - .some( - (record) => - record.command === input.command && record.args.includes(input.workspacePath), - ); + .some((record) => record.editorId === input.editorId && record.path === input.path); }, { timeout: 30_000 }, ) @@ -73,7 +72,14 @@ test.describe("Workspace open in editor", () => { const serverId = requireE2EEnv("E2E_SERVER_ID"); const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH"); await rm(recordPath, { force: true }); - await injectDesktopBridge(page, { serverId }); + await injectDesktopBridge(page, { + serverId, + editorTargets: [ + { id: "cursor", label: "Cursor", kind: "editor" }, + { id: "vscode", label: "VS Code", kind: "editor" }, + ], + editorRecordPath: recordPath, + }); const workspace = await withWorkspace({ prefix: "workspace-editor-target-" }); await workspace.navigateTo(); @@ -81,8 +87,8 @@ test.describe("Workspace open in editor", () => { await chooseEditorTarget(page, "vscode"); await expectEditorOpened({ recordPath, - command: "code", - workspacePath: workspace.repoPath, + editorId: "vscode", + path: workspace.repoPath, afterCount: 0, }); const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length; @@ -94,8 +100,8 @@ test.describe("Workspace open in editor", () => { await page.getByTestId("workspace-open-in-editor-primary").click(); await expectEditorOpened({ recordPath, - command: "code", - workspacePath: workspace.repoPath, + editorId: "vscode", + path: workspace.repoPath, afterCount: recordsAfterSelection, }); const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length; @@ -105,8 +111,8 @@ test.describe("Workspace open in editor", () => { await page.getByTestId("workspace-open-in-editor-primary").click(); await expectEditorOpened({ recordPath, - command: "code", - workspacePath: workspace.repoPath, + editorId: "vscode", + path: workspace.repoPath, afterCount: recordsAfterReturnOpen, }); }); diff --git a/packages/app/src/components/icons/editor-app-icons.tsx b/packages/app/src/components/icons/editor-app-icons.tsx index 2abdba0ed..bb8ec12dc 100644 --- a/packages/app/src/components/icons/editor-app-icons.tsx +++ b/packages/app/src/components/icons/editor-app-icons.tsx @@ -1,11 +1,7 @@ import { SquareTerminal } from "lucide-react-native"; import { useMemo } from "react"; import { Image, type ImageSourcePropType } from "react-native"; -import { - isKnownEditorTargetId, - type EditorTargetId, - type KnownEditorTargetId, -} from "@getpaseo/protocol/messages"; +import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets"; interface EditorAppIconProps { editorId: EditorTargetId; @@ -14,7 +10,7 @@ interface EditorAppIconProps { } /* eslint-disable @typescript-eslint/no-require-imports */ -const EDITOR_APP_IMAGES: Record = { +const EDITOR_APP_IMAGES: Record = { cursor: require("../../../assets/images/editor-apps/cursor.png"), vscode: require("../../../assets/images/editor-apps/vscode.png"), webstorm: require("../../../assets/images/editor-apps/webstorm.png"), @@ -25,15 +21,16 @@ const EDITOR_APP_IMAGES: Record = { }; /* eslint-enable @typescript-eslint/no-require-imports */ -export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId { - return isKnownEditorTargetId(editorId); +export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean { + return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined; } export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) { const imageStyle = useMemo(() => ({ width: size, height: size }), [size]); - if (!hasBundledEditorAppIcon(editorId)) { + const source = EDITOR_APP_IMAGES[editorId]; + if (!source) { return ; } - return ; + return ; } diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index a566d2373..ade806290 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -51,6 +51,24 @@ export interface DesktopOpenerBridge { openUrl?: (url: string) => Promise; } +export interface DesktopEditorTargetDescriptor { + id: string; + label: string; + kind: "editor" | "file-manager"; +} + +export interface DesktopEditorOpenTargetInput { + editorId: string; + path: string; + cwd?: string; + mode?: "open" | "reveal"; +} + +export interface DesktopEditorBridge { + listTargets?: () => Promise; + openTarget?: (input: DesktopEditorOpenTargetInput) => Promise; +} + export interface DesktopWebUtilsBridge { getPathForFile?: (file: File) => string; } @@ -111,6 +129,7 @@ export interface DesktopHostBridge { dialog?: DesktopDialogBridge; notification?: DesktopNotificationBridge; opener?: DesktopOpenerBridge; + editor?: DesktopEditorBridge; webUtils?: DesktopWebUtilsBridge; menu?: DesktopMenuBridge; browser?: DesktopBrowserBridge; diff --git a/packages/app/src/git/github-url.test.ts b/packages/app/src/git/github-url.test.ts index e26ab8b52..9b6329caf 100644 --- a/packages/app/src/git/github-url.test.ts +++ b/packages/app/src/git/github-url.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url"; +import { + buildGitHubBlobUrl, + buildGitHubBranchTreeUrl, + parseGitHubRepoFromRemote, +} from "./github-url"; describe("parseGitHubRepoFromRemote", () => { it.each([ @@ -51,3 +55,78 @@ describe("buildGitHubBranchTreeUrl", () => { ).toBeNull(); }); }); + +describe("buildGitHubBlobUrl", () => { + it("builds a blob URL for a file path", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "git@github.com:acme/repo.git", + branch: "main", + path: "src/index.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts"); + }); + + it("appends a single-line anchor", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "src/index.ts", + lineStart: 12, + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12"); + }); + + it("appends a line range anchor", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "src/index.ts", + lineStart: 12, + lineEnd: 20, + }), + ).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20"); + }); + + it("strips leading slashes and encodes path segments", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "/src/a b/c#d.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts"); + }); + + it("normalizes harmless dot segments in the blob path", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "./src/../index.ts", + }), + ).toBe("https://github.com/acme/repo/blob/main/index.ts"); + }); + + it("returns null for blob paths that escape above the repo root", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "../outside.ts", + }), + ).toBeNull(); + }); + + it("returns null when the path is missing", () => { + expect( + buildGitHubBlobUrl({ + remoteUrl: "https://github.com/acme/repo.git", + branch: "main", + path: "", + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/git/github-url.ts b/packages/app/src/git/github-url.ts index fc1702a5a..875da903a 100644 --- a/packages/app/src/git/github-url.ts +++ b/packages/app/src/git/github-url.ts @@ -20,3 +20,50 @@ export function buildGitHubBranchTreeUrl(input: { const encodedBranch = branch.split("/").map(encodeURIComponent).join("/"); return `https://github.com/${repo}/tree/${encodedBranch}`; } + +function normalizeGitHubBlobPath(path: string | null | undefined): string | null { + const segments: string[] = []; + const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, ""); + if (!trimmed) { + return null; + } + for (const segment of trimmed.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length === 0) { + return null; + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.join("/") || null; +} + +export function buildGitHubBlobUrl(input: { + remoteUrl: string | null | undefined; + branch: string | null | undefined; + path: string | null | undefined; + lineStart?: number; + lineEnd?: number; +}): string | null { + const repo = parseGitHubRepoFromRemote(input.remoteUrl); + const branch = input.branch?.trim(); + const filePath = normalizeGitHubBlobPath(input.path); + if (!repo || !branch || branch === "HEAD" || !filePath) { + return null; + } + const encodedBranch = branch.split("/").map(encodeURIComponent).join("/"); + const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); + let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`; + if (input.lineStart && input.lineStart > 0) { + url += `#L${input.lineStart}`; + if (input.lineEnd && input.lineEnd > input.lineStart) { + url += `-L${input.lineEnd}`; + } + } + return url; +} diff --git a/packages/app/src/hooks/use-preferred-editor.test.ts b/packages/app/src/hooks/use-preferred-editor.test.ts index 97889e38e..49a3c4c18 100644 --- a/packages/app/src/hooks/use-preferred-editor.test.ts +++ b/packages/app/src/hooks/use-preferred-editor.test.ts @@ -20,6 +20,12 @@ describe("resolvePreferredEditorId", () => { ); }); + it("keeps custom script target ids as plain strings", () => { + expect(resolvePreferredEditorId(["script:open-in-nvim", "cursor"], "script:open-in-nvim")).toBe( + "script:open-in-nvim", + ); + }); + it("returns null when no editors are available", () => { expect(resolvePreferredEditorId([], "cursor")).toBeNull(); }); diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts index 9b80f97f6..15b8c1c4c 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { EditorTargetIdSchema, type EditorTargetId } from "@getpaseo/protocol/messages"; +import type { EditorTargetId } from "@/workspace/editor-targets"; const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor"; const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"]; @@ -11,8 +11,7 @@ async function loadPreferredEditor(): Promise { if (!stored) { return null; } - const parsed = EditorTargetIdSchema.safeParse(stored); - return parsed.success ? parsed.data : null; + return stored.trim() || null; } export function resolvePreferredEditorId( diff --git a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx index 103dba42d..184a71802 100644 --- a/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx +++ b/packages/app/src/screens/workspace/workspace-open-in-editor-button.tsx @@ -6,10 +6,9 @@ import { View, type PressableStateCallbackType, } from "react-native"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { Check, ChevronDown } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import type { EditorTargetDescriptorPayload } from "@getpaseo/protocol/messages"; import { EditorAppIcon } from "@/components/icons/editor-app-icons"; import { GitHubIcon } from "@/components/icons/github-icon"; import { @@ -21,18 +20,20 @@ import { import { useToast } from "@/contexts/toast-context"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor"; -import { buildGitHubBranchTreeUrl } from "@/git/github-url"; import { openExternalUrl } from "@/utils/open-external-url"; import { isAbsolutePath } from "@/utils/path"; import { isWeb } from "@/constants/platform"; +import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets"; +import { resolveWorkspaceFilePaths, type WorkspaceFileLocation } from "@/workspace/file-open"; +import { planWorkspaceOpenTargets } from "@/workspace/open-target-planner"; import type { Theme } from "@/styles/theme"; -import { filterTargetsForDaemonLocation } from "./workspace-open-targets"; interface WorkspaceOpenInEditorButtonProps { serverId: string; cwd: string; + activeFile?: WorkspaceFileLocation | null; hideLabels?: boolean; } @@ -40,7 +41,6 @@ interface OpenTarget { id: string; label: string; icon: ReactElement; - requiresLocalDaemon: boolean; onOpen: () => Promise | void; } @@ -80,95 +80,71 @@ function OpenTargetMenuItem({ target, isPreferred, onOpen }: OpenTargetMenuItemP export function WorkspaceOpenInEditorButton({ serverId, cwd, + activeFile, hideLabels, }: WorkspaceOpenInEditorButtonProps) { const toast = useToast(); - const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const isLocalDaemon = useIsLocalDaemon(serverId); const { preferredEditorId, updatePreferredEditor } = usePreferredEditor(); + const { targets: desktopOpenTargets, isAvailable: isDesktopOpenAvailable } = + useDesktopOpenTargets({ + isLocalExecution: isLocalDaemon, + }); - const shouldQueryWorkspace = - isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd); - const shouldLoadEditorTargets = shouldQueryWorkspace && isLocalDaemon; - - const availableEditorsQuery = useQuery({ - queryKey: ["available-editors", serverId], - enabled: shouldLoadEditorTargets, - staleTime: 60_000, - retry: false, - queryFn: async () => { - if (!client) { - return []; - } - try { - const payload = await client.listAvailableEditors(); - return payload.error ? [] : payload.editors; - } catch { - return []; - } - }, - }); - - const availableEditors = useMemo( - () => availableEditorsQuery.data ?? [], - [availableEditorsQuery.data], + const resolvedFile = useMemo( + () => + activeFile ? resolveWorkspaceFilePaths({ path: activeFile.path, workspaceRoot: cwd }) : null, + [activeFile, cwd], ); + const activeFileName = useMemo( + () => resolvedFile?.absolutePath.split("/").findLast(Boolean) ?? null, + [resolvedFile], + ); + + const canResolveWorkspace = isWeb && cwd.trim().length > 0 && isAbsolutePath(cwd); + const shouldQueryCheckout = canResolveWorkspace && isConnected; const { status: checkoutStatus } = useCheckoutStatusQuery({ serverId, - cwd: shouldQueryWorkspace ? cwd : "", + cwd: shouldQueryCheckout ? cwd : "", }); - const editorTargets = useMemo( + const targets = useMemo( () => - availableEditors.map((editor) => ({ - id: editor.id, - label: editor.label, - icon: , - requiresLocalDaemon: true, - onOpen: async () => { - if (!client) { - throw new Error("Host is not connected"); - } - const payload = await client.openInEditor(cwd, editor.id); - if (payload.error) { - throw new Error(payload.error); - } - }, - })), - [availableEditors, client, cwd], - ); - - const githubTarget = useMemo(() => { - if (!checkoutStatus?.isGit) { - return null; - } - const url = buildGitHubBranchTreeUrl({ - remoteUrl: checkoutStatus.remoteUrl, - branch: checkoutStatus.currentBranch, - }); - if (!url) { - return null; - } - return { - id: "github", - label: "GitHub", - icon: , - requiresLocalDaemon: false, - onOpen: () => openExternalUrl(url), - }; - }, [checkoutStatus]); - - const targets = useMemo( - () => - filterTargetsForDaemonLocation( - githubTarget ? [...editorTargets, githubTarget] : editorTargets, - { - isLocalDaemon, - }, - ), - [editorTargets, githubTarget, isLocalDaemon], + planWorkspaceOpenTargets({ + workspaceDirectory: cwd, + activeFile, + resolvedActiveFile: resolvedFile, + desktopTargets: desktopOpenTargets, + canUseDesktopBridge: isDesktopOpenAvailable, + isLocalExecution: isLocalDaemon, + checkoutStatus, + }).map((target) => { + if (target.source === "github") { + return { + id: target.id, + label: target.label, + icon: , + onOpen: () => openExternalUrl(target.url), + }; + } + return { + id: target.id, + label: target.label, + icon: , + onOpen: () => openDesktopTarget(target.openInput), + }; + }), + [ + activeFile, + checkoutStatus, + cwd, + desktopOpenTargets, + isDesktopOpenAvailable, + isLocalDaemon, + resolvedFile, + ], ); const targetIds = useMemo(() => targets.map((target) => target.id), [targets]); @@ -216,7 +192,7 @@ export function WorkspaceOpenInEditorButton({ } }, [primaryOption, handleOpenTarget]); - if (!shouldQueryWorkspace || !primaryOption || targets.length === 0) { + if (!canResolveWorkspace || !primaryOption || targets.length === 0) { return null; } @@ -229,7 +205,11 @@ export function WorkspaceOpenInEditorButton({ onPress={handlePrimaryPress} disabled={openMutation.isPending} accessibilityRole="button" - accessibilityLabel={`Open workspace in ${primaryOption.label}`} + accessibilityLabel={ + activeFileName + ? `Open ${activeFileName} in ${primaryOption.label}` + : `Open workspace in ${primaryOption.label}` + } > {openMutation.isPending ? ( { - const targets = [ - { id: "cursor", requiresLocalDaemon: true }, - { id: "vscode", requiresLocalDaemon: true }, - { id: "github", requiresLocalDaemon: false }, - ]; - - it("keeps local app targets and URL targets for the local daemon", () => { - expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: true })).toEqual(targets); - }); - - it("hides local app targets for a remote daemon", () => { - expect(filterTargetsForDaemonLocation(targets, { isLocalDaemon: false })).toEqual([ - { id: "github", requiresLocalDaemon: false }, - ]); - }); - - it("preserves target order after filtering", () => { - expect( - filterTargetsForDaemonLocation( - [ - { id: "github", requiresLocalDaemon: false }, - { id: "finder", requiresLocalDaemon: true }, - { id: "docs", requiresLocalDaemon: false }, - ], - { isLocalDaemon: false }, - ), - ).toEqual([ - { id: "github", requiresLocalDaemon: false }, - { id: "docs", requiresLocalDaemon: false }, - ]); - }); -}); diff --git a/packages/app/src/screens/workspace/workspace-open-targets.ts b/packages/app/src/screens/workspace/workspace-open-targets.ts deleted file mode 100644 index 52efe5914..000000000 --- a/packages/app/src/screens/workspace/workspace-open-targets.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface WorkspaceOpenTargetAvailability { - requiresLocalDaemon: boolean; -} - -export function filterTargetsForDaemonLocation( - targets: readonly Target[], - input: { isLocalDaemon: boolean }, -): Target[] { - if (input.isLocalDaemon) { - return [...targets]; - } - return targets.filter((target) => !target.requiresLocalDaemon); -} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index cce5f5029..1004b8a5d 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -193,6 +193,31 @@ function getWorkspaceScripts( return workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS; } +interface WorkspaceFileLocationFields { + path: string | null; + lineStart?: number; + lineEnd?: number; +} + +function getWorkspaceFileLocationFields( + tab: WorkspaceTabDescriptor | null, +): WorkspaceFileLocationFields { + const target = tab?.target; + if (target?.kind !== "file") { + return { path: null }; + } + return { path: target.path, lineStart: target.lineStart, lineEnd: target.lineEnd }; +} + +function buildWorkspaceFileLocation( + fields: WorkspaceFileLocationFields, +): WorkspaceFileLocation | null { + if (fields.path === null) { + return null; + } + return { path: fields.path, lineStart: fields.lineStart, lineEnd: fields.lineEnd }; +} + const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedEllipsisVertical = withUnistyles(EllipsisVertical); @@ -2851,6 +2876,19 @@ function WorkspaceScreenContent({ }); const activeTabDescriptor = useMemo(() => activeTab?.descriptor ?? null, [activeTab]); + const activeFileFields = getWorkspaceFileLocationFields(activeTabDescriptor); + const activeFilePath = activeFileFields.path; + const activeFileLineStart = activeFileFields.lineStart; + const activeFileLineEnd = activeFileFields.lineEnd; + const activeFileLocation = useMemo( + () => + buildWorkspaceFileLocation({ + path: activeFilePath, + lineStart: activeFileLineStart, + lineEnd: activeFileLineEnd, + }), + [activeFileLineEnd, activeFileLineStart, activeFilePath], + ); const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const shouldRenderDesktopPaneFallback = useMemo( () => !isMobile && !canRenderDesktopPaneSplits, @@ -3083,10 +3121,11 @@ function WorkspaceScreenContent({ hideLabels={showCompactButtonLabels} /> ) : null} - {!isMobile ? ( + {!isMobile && workspaceDirectory ? ( ) : null} @@ -3193,6 +3232,8 @@ function WorkspaceScreenContent({ workspaceDescriptor, normalizedServerId, normalizedWorkspaceId, + workspaceDirectory, + activeFileLocation, liveTerminalIds, handleScriptTerminalStarted, handleViewScriptTerminal, diff --git a/packages/app/src/workspace/desktop-open-targets.ts b/packages/app/src/workspace/desktop-open-targets.ts new file mode 100644 index 000000000..63b483c40 --- /dev/null +++ b/packages/app/src/workspace/desktop-open-targets.ts @@ -0,0 +1,71 @@ +import { useQuery } from "@tanstack/react-query"; +import { getDesktopHost, type DesktopEditorBridge } from "@/desktop/host"; + +export type DesktopOpenTargetKind = "editor" | "file-manager"; +export type DesktopOpenMode = "open" | "reveal"; + +export interface DesktopOpenTarget { + id: string; + label: string; + kind: DesktopOpenTargetKind; +} + +export interface OpenDesktopTargetInput { + editorId: string; + path: string; + cwd?: string; + mode?: DesktopOpenMode; +} + +interface AvailableDesktopEditorBridge { + listTargets: NonNullable; + openTarget: NonNullable; +} + +function getDesktopEditorBridge(): AvailableDesktopEditorBridge | null { + const bridge = getDesktopHost()?.editor; + if (!bridge?.listTargets || !bridge.openTarget) { + return null; + } + return { + listTargets: bridge.listTargets, + openTarget: bridge.openTarget, + }; +} + +export function hasDesktopOpenTargetsBridge(): boolean { + return getDesktopEditorBridge() !== null; +} + +export async function listDesktopOpenTargets(): Promise { + const bridge = getDesktopEditorBridge(); + if (!bridge) { + return []; + } + return await bridge.listTargets(); +} + +export async function openDesktopTarget(input: OpenDesktopTargetInput): Promise { + const bridge = getDesktopEditorBridge(); + if (!bridge) { + throw new Error("Desktop editor bridge is unavailable"); + } + await bridge.openTarget(input); +} + +export function useDesktopOpenTargets(input: { isLocalExecution: boolean }) { + const hasBridge = hasDesktopOpenTargetsBridge(); + const canListTargets = hasBridge && input.isLocalExecution; + const query = useQuery({ + queryKey: ["desktop-open-targets"], + enabled: canListTargets, + staleTime: 60_000, + retry: false, + queryFn: listDesktopOpenTargets, + }); + + return { + targets: query.data ?? [], + isAvailable: canListTargets, + }; +} diff --git a/packages/app/src/workspace/editor-targets.test.ts b/packages/app/src/workspace/editor-targets.test.ts new file mode 100644 index 000000000..4e5ea78ff --- /dev/null +++ b/packages/app/src/workspace/editor-targets.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { isKnownEditorTargetId } from "./editor-targets"; + +describe("editor target ids", () => { + it("recognizes built-ins without typing custom ids out of the system", () => { + const customEditorId: string = "script:open-in-nvim"; + + expect(isKnownEditorTargetId("vscode")).toBe(true); + expect(isKnownEditorTargetId(customEditorId)).toBe(false); + }); +}); diff --git a/packages/app/src/workspace/editor-targets.ts b/packages/app/src/workspace/editor-targets.ts new file mode 100644 index 000000000..a14395223 --- /dev/null +++ b/packages/app/src/workspace/editor-targets.ts @@ -0,0 +1,15 @@ +export type EditorTargetId = string; + +const KNOWN_EDITOR_TARGET_IDS: ReadonlySet = new Set([ + "cursor", + "vscode", + "webstorm", + "zed", + "finder", + "explorer", + "file-manager", +]); + +export function isKnownEditorTargetId(editorId: EditorTargetId): boolean { + return KNOWN_EDITOR_TARGET_IDS.has(editorId); +} diff --git a/packages/app/src/workspace/file-open/index.test.ts b/packages/app/src/workspace/file-open/index.test.ts index a54d791f4..6a31ac6ab 100644 --- a/packages/app/src/workspace/file-open/index.test.ts +++ b/packages/app/src/workspace/file-open/index.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { createWorkspaceFileTabTarget, normalizeWorkspaceFileLocation, + resolveWorkspaceFilePaths, workspaceFileLocationsEqual, } from "."; @@ -61,3 +62,93 @@ describe("workspace file tab targets", () => { ).toBe(false); }); }); + +describe("resolveWorkspaceFilePaths", () => { + it("joins workspace-relative paths against the workspace root", () => { + expect( + resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "/Users/me/repo" }), + ).toEqual({ + absolutePath: "/Users/me/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("derives the relative path for an absolute file inside the workspace", () => { + expect( + resolveWorkspaceFilePaths({ + path: "/Users/me/repo/src/app.ts", + workspaceRoot: "/Users/me/repo", + }), + ).toEqual({ + absolutePath: "/Users/me/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("keeps the absolute path but drops the relative path when outside the workspace", () => { + expect( + resolveWorkspaceFilePaths({ + path: "/etc/hosts", + workspaceRoot: "/Users/me/repo", + }), + ).toEqual({ + absolutePath: "/etc/hosts", + relativePath: null, + }); + }); + + it("normalizes Windows separators in the file path", () => { + expect( + resolveWorkspaceFilePaths({ path: "src\\app.ts", workspaceRoot: "/Users/me/repo" }), + ).toEqual({ + absolutePath: "/Users/me/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("normalizes dot segments in workspace-relative paths", () => { + expect( + resolveWorkspaceFilePaths({ path: "./src/../app.ts", workspaceRoot: "/Users/me/repo" }), + ).toEqual({ + absolutePath: "/Users/me/repo/app.ts", + relativePath: "app.ts", + }); + }); + + it("rejects workspace-relative paths that escape the workspace", () => { + expect( + resolveWorkspaceFilePaths({ path: "../outside.ts", workspaceRoot: "/Users/me/repo" }), + ).toBeNull(); + expect( + resolveWorkspaceFilePaths({ path: "src/../../outside.ts", workspaceRoot: "/Users/me/repo" }), + ).toBeNull(); + }); + + it("derives the relative path for a Windows absolute file inside the workspace", () => { + expect( + resolveWorkspaceFilePaths({ + path: "C:\\Users\\me\\repo\\src\\app.ts", + workspaceRoot: "C:\\Users\\me\\repo", + }), + ).toEqual({ + absolutePath: "C:/Users/me/repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("returns null for home-relative paths that cannot be anchored", () => { + expect( + resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }), + ).toBeNull(); + }); + + it("returns null when the workspace root is not absolute", () => { + expect(resolveWorkspaceFilePaths({ path: "src/app.ts", workspaceRoot: "repo" })).toBeNull(); + }); + + it("returns null when the resolved file equals the workspace root", () => { + expect( + resolveWorkspaceFilePaths({ path: "/Users/me/repo", workspaceRoot: "/Users/me/repo" }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/workspace/file-open/index.ts b/packages/app/src/workspace/file-open/index.ts index c79c77fa0..4a14a78a5 100644 --- a/packages/app/src/workspace/file-open/index.ts +++ b/packages/app/src/workspace/file-open/index.ts @@ -1,3 +1,5 @@ +import { isAbsolutePath } from "@/utils/path"; + export type OpenFileDisposition = "main" | "side"; export interface WorkspaceFileLocation { @@ -57,3 +59,103 @@ function normalizeLineNumber(value: number | null | undefined): number | undefin ? Math.floor(value) : undefined; } + +function trimTrailingSlashes(value: string): string { + if (value === "/" || /^\/+$/.test(value)) { + return "/"; + } + if (/^[A-Za-z]:\/+$/.test(value)) { + return `${value.slice(0, 2)}/`; + } + return value.replace(/\/+$/, ""); +} + +function normalizePathSegments(value: string, rejectEscape: boolean): string | null { + const segments: string[] = []; + for (const segment of value.split("/")) { + if (!segment || segment === ".") { + continue; + } + if (segment === "..") { + if (segments.length === 0) { + if (rejectEscape) { + return null; + } + continue; + } + segments.pop(); + continue; + } + segments.push(segment); + } + return segments.join("/"); +} + +function normalizeAbsolutePath(value: string): string | null { + const normalizedInput = trimTrailingSlashes(value); + if (!isAbsolutePath(normalizedInput)) { + return null; + } + + const drivePath = /^([A-Za-z]:)\/(.*)$/.exec(normalizedInput); + if (drivePath) { + const normalizedBody = normalizePathSegments(drivePath[2], false); + return trimTrailingSlashes(`${drivePath[1]}/${normalizedBody}`); + } + + const prefix = normalizedInput.startsWith("//") ? "//" : "/"; + const normalizedBody = normalizePathSegments(normalizedInput.replace(/^\/+/, ""), false); + return trimTrailingSlashes(`${prefix}${normalizedBody}`); +} + +function normalizeRelativePath(value: string): string | null { + const normalized = normalizePathSegments(value.replace(/^\/+/, ""), true); + return normalized || null; +} + +export interface ResolvedWorkspaceFilePaths { + /** Absolute path on the host, suitable for opening in an editor / file manager. */ + absolutePath: string; + /** Path relative to the workspace root, or null when the file lives outside it. */ + relativePath: string | null; +} + +/** + * Resolves a file tab's path (which may be workspace-relative) against the workspace + * root. Returns null when an absolute host path cannot be derived — e.g. a `~`-relative + * path or a relative path with no workspace root to anchor it. + */ +export function resolveWorkspaceFilePaths(input: { + path: string; + workspaceRoot: string; +}): ResolvedWorkspaceFilePaths | null { + const filePath = input.path.trim().replace(/\\/g, "/"); + const workspaceRoot = normalizeAbsolutePath(input.workspaceRoot.trim().replace(/\\/g, "/")); + if (!filePath || !workspaceRoot) { + return null; + } + + if (isAbsolutePath(filePath)) { + const normalizedFile = normalizeAbsolutePath(filePath); + if (!normalizedFile) { + return null; + } + if (normalizedFile === workspaceRoot) { + return null; + } + const relativePath = normalizedFile.startsWith(`${workspaceRoot}/`) + ? normalizedFile.slice(workspaceRoot.length + 1) + : null; + return { absolutePath: normalizedFile, relativePath }; + } + + if (filePath.startsWith("~")) { + return null; + } + + const relativePath = normalizeRelativePath(filePath); + if (!relativePath) { + return null; + } + return { absolutePath: `${workspaceRoot}/${relativePath}`, relativePath }; +} diff --git a/packages/app/src/workspace/open-target-planner.test.ts b/packages/app/src/workspace/open-target-planner.test.ts new file mode 100644 index 000000000..19acff6cf --- /dev/null +++ b/packages/app/src/workspace/open-target-planner.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { planWorkspaceOpenTargets } from "./open-target-planner"; + +const desktopTargets = [ + { id: "vscode", label: "VS Code", kind: "editor" as const }, + { id: "finder", label: "Finder", kind: "file-manager" as const }, +]; + +const checkoutStatus = { + isGit: true, + remoteUrl: "git@github.com:getpaseo/paseo.git", + currentBranch: "main", +}; + +describe("planWorkspaceOpenTargets", () => { + it("plans editor targets with active-file absolute path and cwd", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 }, + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: true, + }); + + expect(targets[0]).toMatchObject({ + source: "desktop", + id: "vscode", + openInput: { editorId: "vscode", path: "/repo/src/app.ts", cwd: "/repo" }, + }); + }); + + it("plans file-manager targets with active-file absolute path and reveal mode", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + activeFile: { path: "src/app.ts" }, + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: true, + }); + + expect(targets[1]).toMatchObject({ + source: "desktop", + id: "finder", + openInput: { editorId: "finder", path: "/repo/src/app.ts", mode: "reveal" }, + }); + }); + + it("plans no active file as opening the workspace folder", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: true, + }); + + expect(targets[0]).toMatchObject({ + source: "desktop", + id: "vscode", + openInput: { editorId: "vscode", path: "/repo" }, + }); + expect(targets[1]).toMatchObject({ + source: "desktop", + id: "finder", + openInput: { editorId: "finder", path: "/repo" }, + }); + }); + + it("passes custom target ids through as strings", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + activeFile: { path: "src/app.ts" }, + desktopTargets: [{ id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }], + canUseDesktopBridge: true, + isLocalExecution: true, + }); + + expect(targets).toEqual([ + { + source: "desktop", + id: "script:open-in-nvim", + label: "Open in Neovim", + editorId: "script:open-in-nvim", + openInput: { + editorId: "script:open-in-nvim", + path: "/repo/src/app.ts", + cwd: "/repo", + }, + }, + ]); + }); + + it("keeps GitHub target independent and uses blob and tree URLs", () => { + const blobTargets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + activeFile: { path: "src/app.ts", lineStart: 3, lineEnd: 5 }, + desktopTargets: [], + canUseDesktopBridge: false, + isLocalExecution: false, + checkoutStatus, + }); + const treeTargets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + desktopTargets: [], + canUseDesktopBridge: false, + isLocalExecution: false, + checkoutStatus, + }); + + expect(blobTargets).toEqual([ + { + source: "github", + id: "github", + label: "GitHub", + url: "https://github.com/getpaseo/paseo/blob/main/src/app.ts#L3-L5", + }, + ]); + expect(treeTargets).toEqual([ + { + source: "github", + id: "github", + label: "GitHub", + url: "https://github.com/getpaseo/paseo/tree/main", + }, + ]); + }); + + it("suppresses desktop targets when Electron bridge is unavailable", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + desktopTargets, + canUseDesktopBridge: false, + isLocalExecution: true, + checkoutStatus, + }); + + expect(targets.map((target) => target.id)).toEqual(["github"]); + }); + + it("suppresses desktop targets for remote execution paths", () => { + const targets = planWorkspaceOpenTargets({ + workspaceDirectory: "/repo", + desktopTargets, + canUseDesktopBridge: true, + isLocalExecution: false, + checkoutStatus, + }); + + expect(targets.map((target) => target.id)).toEqual(["github"]); + }); +}); diff --git a/packages/app/src/workspace/open-target-planner.ts b/packages/app/src/workspace/open-target-planner.ts new file mode 100644 index 000000000..9539df4f0 --- /dev/null +++ b/packages/app/src/workspace/open-target-planner.ts @@ -0,0 +1,146 @@ +import { buildGitHubBlobUrl, buildGitHubBranchTreeUrl } from "@/git/github-url"; +import type { DesktopOpenTarget, OpenDesktopTargetInput } from "@/workspace/desktop-open-targets"; +import { + type ResolvedWorkspaceFilePaths, + resolveWorkspaceFilePaths, + type WorkspaceFileLocation, +} from "@/workspace/file-open"; + +interface CheckoutStatusForOpenTarget { + isGit: boolean; + remoteUrl?: string | null; + currentBranch?: string | null; +} + +export interface PlannedDesktopOpenTarget { + source: "desktop"; + id: string; + label: string; + editorId: string; + openInput: OpenDesktopTargetInput; +} + +export interface PlannedGitHubOpenTarget { + source: "github"; + id: "github"; + label: "GitHub"; + url: string; +} + +export type PlannedWorkspaceOpenTarget = PlannedDesktopOpenTarget | PlannedGitHubOpenTarget; + +export interface PlanWorkspaceOpenTargetsInput { + workspaceDirectory: string; + activeFile?: WorkspaceFileLocation | null; + resolvedActiveFile?: ResolvedWorkspaceFilePaths | null; + desktopTargets: readonly DesktopOpenTarget[]; + canUseDesktopBridge: boolean; + isLocalExecution: boolean; + checkoutStatus?: CheckoutStatusForOpenTarget | null; +} + +function resolveActiveFileForOpenTargets( + input: Pick< + PlanWorkspaceOpenTargetsInput, + "activeFile" | "resolvedActiveFile" | "workspaceDirectory" + >, +): ResolvedWorkspaceFilePaths | null { + if (input.resolvedActiveFile !== undefined) { + return input.resolvedActiveFile; + } + return input.activeFile + ? resolveWorkspaceFilePaths({ + path: input.activeFile.path, + workspaceRoot: input.workspaceDirectory, + }) + : null; +} + +function planDesktopOpenTargets(input: { + workspaceDirectory: string; + resolvedFile: ResolvedWorkspaceFilePaths | null; + desktopTargets: readonly DesktopOpenTarget[]; + canUseDesktopBridge: boolean; + isLocalExecution: boolean; +}): PlannedDesktopOpenTarget[] { + if (!input.canUseDesktopBridge || !input.isLocalExecution) { + return []; + } + + return input.desktopTargets.map((target) => { + if (!input.resolvedFile) { + return { + source: "desktop", + id: target.id, + label: target.label, + editorId: target.id, + openInput: { editorId: target.id, path: input.workspaceDirectory }, + }; + } + if (target.kind === "editor") { + return { + source: "desktop", + id: target.id, + label: target.label, + editorId: target.id, + openInput: { + editorId: target.id, + path: input.resolvedFile.absolutePath, + cwd: input.workspaceDirectory, + }, + }; + } + return { + source: "desktop", + id: target.id, + label: target.label, + editorId: target.id, + openInput: { + editorId: target.id, + path: input.resolvedFile.absolutePath, + mode: "reveal", + }, + }; + }); +} + +function planGitHubOpenTarget(input: { + activeFile?: WorkspaceFileLocation | null; + resolvedFile: ResolvedWorkspaceFilePaths | null; + checkoutStatus?: CheckoutStatusForOpenTarget | null; +}): PlannedGitHubOpenTarget | null { + if (!input.checkoutStatus?.isGit) { + return null; + } + const url = input.resolvedFile?.relativePath + ? buildGitHubBlobUrl({ + remoteUrl: input.checkoutStatus.remoteUrl, + branch: input.checkoutStatus.currentBranch, + path: input.resolvedFile.relativePath, + lineStart: input.activeFile?.lineStart, + lineEnd: input.activeFile?.lineEnd, + }) + : buildGitHubBranchTreeUrl({ + remoteUrl: input.checkoutStatus.remoteUrl, + branch: input.checkoutStatus.currentBranch, + }); + + if (!url) { + return null; + } + return { + source: "github", + id: "github", + label: "GitHub", + url, + }; +} + +export function planWorkspaceOpenTargets( + input: PlanWorkspaceOpenTargetsInput, +): PlannedWorkspaceOpenTarget[] { + const resolvedFile = resolveActiveFileForOpenTargets(input); + const desktopTargets = planDesktopOpenTargets({ ...input, resolvedFile }); + const githubTarget = planGitHubOpenTarget({ ...input, resolvedFile }); + return githubTarget ? [...desktopTargets, githubTarget] : desktopTargets; +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index a43ca6e09..5a90120cc 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -50,8 +50,6 @@ import type { PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, ProjectIconResponse, - ListAvailableEditorsResponseMessage, - OpenInEditorResponseMessage, OpenProjectResponseMessage, ArchiveWorkspaceResponseMessage, WorkspaceSetupStatusResponseMessage, @@ -77,7 +75,6 @@ import type { SessionInboundMessage, SessionOutboundMessage, SendAgentMessageRequest, - EditorTargetId, PaseoConfigRaw, PaseoConfigRevision, } from "@getpaseo/protocol/messages"; @@ -641,12 +638,9 @@ export interface RenameTerminalInput { title: string; requestId?: string; } -type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"]; -type OpenInEditorPayload = OpenInEditorResponseMessage["payload"]; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage["payload"]; type WorkspaceSetupStatusPayload = WorkspaceSetupStatusResponseMessage["payload"]; -export type EditorTargetDescriptor = ListAvailableEditorsPayload["editors"][number]; export interface FetchAgentResult { agent: AgentSnapshotPayload; @@ -1778,34 +1772,6 @@ export class DaemonClient { }); } - async listAvailableEditors(requestId?: string): Promise { - return this.sendCorrelatedSessionRequest({ - requestId, - message: { - type: "list_available_editors_request", - }, - responseType: "list_available_editors_response", - timeout: 10000, - }); - } - - async openInEditor( - path: string, - editorId: EditorTargetId, - requestId?: string, - ): Promise { - return this.sendCorrelatedSessionRequest({ - requestId, - message: { - type: "open_in_editor_request", - path, - editorId, - }, - responseType: "open_in_editor_response", - timeout: 10000, - }); - } - async archiveWorkspace( workspaceId: string, requestId?: string, diff --git a/packages/desktop/src/features/editor-targets.test.ts b/packages/desktop/src/features/editor-targets.test.ts new file mode 100644 index 000000000..d31a51057 --- /dev/null +++ b/packages/desktop/src/features/editor-targets.test.ts @@ -0,0 +1,459 @@ +import type { SpawnOptions } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { + listAvailableEditorTargets, + openEditorTarget, + registerEditorTargetHandlers, +} from "./editor-targets"; + +interface SpawnCall { + command: string; + args: string[]; + options: SpawnOptions; + listenedForError: boolean; + listenedForSpawn: boolean; + unrefed: boolean; +} + +function createExistsSync(paths: readonly string[]): (path: string) => boolean { + const availablePaths = new Set(paths); + return (path) => availablePaths.has(path); +} + +function createSpawnRecorder() { + const calls: SpawnCall[] = []; + const spawn = (command: string, args: string[], options: SpawnOptions) => { + const call: SpawnCall = { + command, + args, + options, + listenedForError: false, + listenedForSpawn: false, + unrefed: false, + }; + calls.push(call); + const child = { + once: (event: "error" | "spawn", handler: (error?: Error) => void) => { + if (event === "error") { + call.listenedForError = true; + } + if (event === "spawn") { + call.listenedForSpawn = true; + queueMicrotask(() => handler()); + } + return child; + }, + unref: () => { + call.unrefed = true; + }, + }; + return child; + }; + return { calls, spawn }; +} + +describe("desktop editor targets", () => { + it("lists all known editor targets for the platform in deterministic order", () => { + const targets = listAvailableEditorTargets({ + platform: "win32", + env: { PATH: "C:/bin" }, + existsSync: createExistsSync([ + "C:/bin/cursor.exe", + "C:/bin/code.exe", + "C:/bin/webstorm.exe", + "C:/bin/zed.exe", + "C:/bin/explorer.exe", + ]), + }); + + expect(targets).toEqual([ + { id: "cursor", label: "Cursor", kind: "editor" }, + { id: "vscode", label: "VS Code", kind: "editor" }, + { id: "webstorm", label: "WebStorm", kind: "editor" }, + { id: "zed", label: "Zed", kind: "editor" }, + { id: "explorer", label: "Explorer", kind: "file-manager" }, + ]); + }); + + it("lists Finder on macOS", () => { + const targets = listAvailableEditorTargets({ + platform: "darwin", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/usr/bin/open"]), + }); + + expect(targets).toEqual([{ id: "finder", label: "Finder", kind: "file-manager" }]); + }); + + it("lists the generic file manager on Linux", () => { + const targets = listAvailableEditorTargets({ + platform: "linux", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/usr/bin/xdg-open"]), + }); + + expect(targets).toEqual([{ id: "file-manager", label: "File Manager", kind: "file-manager" }]); + }); + + it("can list future custom script targets without changing bridge types", () => { + const targets = listAvailableEditorTargets({ + platform: "linux", + env: { PATH: "/usr/local/bin" }, + existsSync: createExistsSync(["/usr/local/bin/open-in-nvim"]), + targetDefinitions: [ + { + id: "script:open-in-nvim", + label: "Open in Neovim", + kind: "editor", + command: "open-in-nvim", + }, + ], + }); + + expect(targets).toEqual([ + { id: "script:open-in-nvim", label: "Open in Neovim", kind: "editor" }, + ]); + }); + + it("launches editors as detached external processes", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "vscode", path: "/tmp/repo" }, + { + platform: "darwin", + env: { PATH: "/usr/local/bin", ELECTRON_RUN_AS_NODE: "1" }, + existsSync: createExistsSync(["/tmp/repo", "/usr/local/bin/code"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls).toEqual([ + { + command: "/usr/local/bin/code", + args: ["/tmp/repo"], + options: { + detached: true, + env: { PATH: "/usr/local/bin" }, + shell: false, + stdio: "ignore", + }, + listenedForError: true, + listenedForSpawn: true, + unrefed: true, + }, + ]); + }); + + it("reveals files in Finder on macOS", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "finder", path: "/tmp/repo/src/index.ts", mode: "reveal" }, + { + platform: "darwin", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/tmp/repo/src/index.ts", "/usr/bin/open"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.command).toBe("/usr/bin/open"); + expect(recorder.calls[0]?.args).toEqual(["-R", "/tmp/repo/src/index.ts"]); + }); + + it("reveals files in Explorer on Windows", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "explorer", path: "C:/repo/src/index.ts", mode: "reveal" }, + { + platform: "win32", + env: { PATH: "C:/Windows" }, + existsSync: createExistsSync(["C:/repo/src/index.ts", "C:/Windows/explorer.exe"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.command).toBe("C:/Windows/explorer.exe"); + expect(recorder.calls[0]?.args).toEqual(["/select,", "C:/repo/src/index.ts"]); + expect(recorder.calls[0]?.options.shell).toBe(false); + }); + + it("keeps Windows shell metacharacters literal for direct executables", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "explorer", path: "C:/repo/src/file & calculator.ts", mode: "reveal" }, + { + platform: "win32", + env: { PATH: "C:/Windows" }, + existsSync: createExistsSync([ + "C:/repo/src/file & calculator.ts", + "C:/Windows/explorer.exe", + ]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]).toMatchObject({ + command: "C:/Windows/explorer.exe", + args: ["/select,", "C:/repo/src/file & calculator.ts"], + options: { shell: false }, + }); + }); + + it("quotes Windows command-script paths without corrupting metacharacters", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { + editorId: "vscode", + path: "C:/repo/src/file & calculator.ts", + cwd: "C:/repo & workspace", + }, + { + platform: "win32", + env: { PATH: "C:/Program Files/Editors & Tools/bin" }, + existsSync: createExistsSync([ + "C:/repo/src/file & calculator.ts", + "C:/repo & workspace", + "C:/Program Files/Editors & Tools/bin/code.cmd", + ]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]).toMatchObject({ + command: '"C:/Program Files/Editors & Tools/bin/code.cmd"', + args: ['"C:/repo & workspace"', '"C:/repo/src/file & calculator.ts"'], + options: { shell: true }, + }); + }); + + it("quotes Windows command-script values that contain shell metacharacters", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { + editorId: "vscode", + path: "C:/repo/src/file&calculator.ts", + }, + { + platform: "win32", + env: { PATH: "C:/Editors&Tools/bin" }, + existsSync: createExistsSync([ + "C:/repo/src/file&calculator.ts", + "C:/Editors&Tools/bin/code.cmd", + ]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]).toMatchObject({ + command: '"C:/Editors&Tools/bin/code.cmd"', + args: ['"C:/repo/src/file&calculator.ts"'], + options: { shell: true }, + }); + }); + + it("reveals Linux files by opening the containing folder", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "file-manager", path: "/home/user/repo/src/index.ts", mode: "reveal" }, + { + platform: "linux", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/home/user/repo/src/index.ts", "/usr/bin/xdg-open"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.args).toEqual(["/home/user/repo/src"]); + }); + + it("ignores reveal mode for editor targets", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "vscode", path: "/home/user/repo/src/index.ts", mode: "reveal" }, + { + platform: "linux", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/home/user/repo/src/index.ts", "/usr/bin/code"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.args).toEqual(["/home/user/repo/src/index.ts"]); + }); + + it("opens the workspace folder alongside the file for editor targets", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "/tmp/repo" }, + { + platform: "darwin", + env: { PATH: "/usr/local/bin" }, + existsSync: createExistsSync([ + "/tmp/repo/src/index.ts", + "/tmp/repo", + "/usr/local/bin/code", + ]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.args).toEqual(["/tmp/repo", "/tmp/repo/src/index.ts"]); + }); + + it("can launch future custom script targets by string id", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "script:open-in-nvim", path: "/tmp/repo/src/index.ts", cwd: "/tmp/repo" }, + { + platform: "linux", + env: { PATH: "/usr/local/bin" }, + existsSync: createExistsSync([ + "/tmp/repo/src/index.ts", + "/tmp/repo", + "/usr/local/bin/open-in-nvim", + ]), + spawn: recorder.spawn, + targetDefinitions: [ + { + id: "script:open-in-nvim", + label: "Open in Neovim", + kind: "editor", + command: "open-in-nvim", + }, + ], + }, + ); + + expect(recorder.calls[0]).toMatchObject({ + command: "/usr/local/bin/open-in-nvim", + args: ["/tmp/repo", "/tmp/repo/src/index.ts"], + options: { shell: false }, + }); + }); + + it("does not prepend invalid, equal, relative, or missing cwd values", async () => { + const inputs = [ + { editorId: "vscode", path: "/tmp/repo", cwd: "/tmp/repo" }, + { editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "repo" }, + { editorId: "vscode", path: "/tmp/repo/src/index.ts", cwd: "/tmp/missing" }, + ]; + + for (const input of inputs) { + const recorder = createSpawnRecorder(); + await openEditorTarget(input, { + platform: "darwin", + env: { PATH: "/usr/local/bin" }, + existsSync: createExistsSync([ + "/tmp/repo", + "/tmp/repo/src/index.ts", + "/usr/local/bin/code", + ]), + spawn: recorder.spawn, + }); + + expect(recorder.calls[0]?.args).toEqual([input.path]); + } + }); + + it("does not prepend cwd for file-manager targets", async () => { + const recorder = createSpawnRecorder(); + + await openEditorTarget( + { editorId: "finder", path: "/tmp/repo/src", cwd: "/tmp/repo" }, + { + platform: "darwin", + env: { PATH: "/usr/bin" }, + existsSync: createExistsSync(["/tmp/repo/src", "/tmp/repo", "/usr/bin/open"]), + spawn: recorder.spawn, + }, + ); + + expect(recorder.calls[0]?.args).toEqual(["/tmp/repo/src"]); + }); + + it("rejects relative and missing paths", async () => { + await expect( + openEditorTarget( + { editorId: "cursor", path: "repo" }, + { existsSync: () => true, env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Editor target path must be an absolute local path"); + + await expect( + openEditorTarget( + { editorId: "cursor", path: "/tmp/repo" }, + { existsSync: () => false, env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Path does not exist: /tmp/repo"); + }); + + it("rejects unsupported, unknown, and missing executable targets", async () => { + await expect( + openEditorTarget( + { editorId: "finder", path: "/tmp/repo" }, + { platform: "linux", existsSync: () => true, env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Editor target unavailable: Finder"); + + await expect( + openEditorTarget( + { editorId: "unknown-editor", path: "/tmp/repo" }, + { existsSync: () => true, env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Unknown editor target: unknown-editor"); + + await expect( + openEditorTarget( + { editorId: "vscode", path: "/tmp/repo" }, + { existsSync: (path) => path === "/tmp/repo", env: { PATH: "/bin" } }, + ), + ).rejects.toThrow("Editor target unavailable: VS Code"); + }); + + it("runs list and open behavior through registered IPC handlers", async () => { + const handlers = new Map unknown>(); + const ipc = { + handle: (channel: string, listener: (event: unknown, ...args: unknown[]) => unknown) => { + handlers.set(channel, listener); + }, + }; + const recorder = createSpawnRecorder(); + + registerEditorTargetHandlers({ + ipc, + dependencies: { + platform: "darwin", + env: { PATH: "/usr/local/bin:/usr/bin" }, + existsSync: createExistsSync(["/tmp/repo", "/usr/local/bin/code", "/usr/bin/open"]), + spawn: recorder.spawn, + }, + }); + + const listHandler = handlers.get("paseo:editor:listTargets"); + const openHandler = handlers.get("paseo:editor:openTarget"); + if (!listHandler || !openHandler) { + throw new Error("editor IPC handlers were not registered"); + } + + expect(listHandler({})).toEqual([ + { id: "vscode", label: "VS Code", kind: "editor" }, + { id: "finder", label: "Finder", kind: "file-manager" }, + ]); + await openHandler({}, { editorId: "vscode", path: "/tmp/repo" }); + await expect(openHandler({}, { editorId: "vscode", path: "repo" })).rejects.toThrow( + "Editor target path must be an absolute local path", + ); + + expect(recorder.calls[0]?.args).toEqual(["/tmp/repo"]); + }); +}); diff --git a/packages/desktop/src/features/editor-targets.ts b/packages/desktop/src/features/editor-targets.ts new file mode 100644 index 000000000..ea70a1ab4 --- /dev/null +++ b/packages/desktop/src/features/editor-targets.ts @@ -0,0 +1,353 @@ +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { spawn as nodeSpawn } from "node:child_process"; +import { existsSync as nodeExistsSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { ipcMain } from "electron"; +import { z } from "zod"; + +type EditorTargetKind = "editor" | "file-manager"; +type OpenEditorMode = "open" | "reveal"; + +interface EditorTargetDefinition { + id: string; + label: string; + kind: EditorTargetKind; + command: string; + platforms?: readonly NodeJS.Platform[]; + excludedPlatforms?: readonly NodeJS.Platform[]; +} + +export interface DesktopEditorTargetDescriptor { + id: string; + label: string; + kind: EditorTargetKind; +} + +export interface OpenEditorTargetInput { + editorId: string; + path: string; + cwd?: string; + mode?: OpenEditorMode; +} + +interface ListEditorTargetsDependencies { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + existsSync?: (path: string) => boolean; + targetDefinitions?: readonly EditorTargetDefinition[]; +} + +interface SpawnedProcess { + once(event: "error", handler: (error: Error) => void): SpawnedProcess; + once(event: "spawn", handler: () => void): SpawnedProcess; + unref(): void; +} + +interface OpenEditorTargetDependencies extends ListEditorTargetsDependencies { + spawn?: (command: string, args: string[], options: SpawnOptions) => SpawnedProcess; +} + +interface IpcHandlerRegistry { + handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void; +} + +interface Launch { + command: string; + args: string[]; +} + +interface SpawnLaunch extends Launch { + shell: boolean; +} + +const RUNTIME_CONTROL_ENV_KEYS = [ + "PASEO_NODE_ENV", + "PASEO_DESKTOP_MANAGED", + "PASEO_SUPERVISED", + "ELECTRON_RUN_AS_NODE", + "ELECTRON_NO_ATTACH_CONSOLE", +] as const; + +const BUILT_IN_EDITOR_TARGETS: readonly EditorTargetDefinition[] = [ + { id: "cursor", label: "Cursor", kind: "editor", command: "cursor" }, + { id: "vscode", label: "VS Code", kind: "editor", command: "code" }, + { id: "webstorm", label: "WebStorm", kind: "editor", command: "webstorm" }, + { id: "zed", label: "Zed", kind: "editor", command: "zed" }, + { + id: "finder", + label: "Finder", + kind: "file-manager", + command: "open", + platforms: ["darwin"], + }, + { + id: "explorer", + label: "Explorer", + kind: "file-manager", + command: "explorer", + platforms: ["win32"], + }, + { + id: "file-manager", + label: "File Manager", + kind: "file-manager", + command: "xdg-open", + excludedPlatforms: ["darwin", "win32"], + }, +]; + +const OpenEditorTargetInputSchema = z.object({ + editorId: z.string().trim().min(1), + path: z.string().trim().min(1), + cwd: z.string().trim().min(1).optional(), + mode: z.enum(["open", "reveal"]).optional(), +}); + +function isTargetSupportedOnPlatform( + target: EditorTargetDefinition, + platform: NodeJS.Platform, +): boolean { + if (target.platforms && !target.platforms.includes(platform)) { + return false; + } + if (target.excludedPlatforms?.includes(platform)) { + return false; + } + return true; +} + +function createExternalProcessEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { ...baseEnv }; + for (const key of RUNTIME_CONTROL_ENV_KEYS) { + delete env[key]; + } + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete env[key]; + } + } + return env; +} + +function resolveExecutable( + command: string, + input: { + env: NodeJS.ProcessEnv; + existsSync: (path: string) => boolean; + platform: NodeJS.Platform; + }, +): string | null { + if (isAbsolutePath(command, input.platform) && input.existsSync(command)) { + return command; + } + const pathValue = input.env.PATH ?? input.env.Path ?? input.env.path ?? ""; + const pathDelimiter = input.platform === "win32" ? ";" : ":"; + for (const directory of pathValue.split(pathDelimiter)) { + if (!directory) { + continue; + } + const candidate = `${directory}/${command}`; + if (input.existsSync(candidate)) { + return candidate; + } + if (input.platform === "win32") { + for (const extension of [".exe", ".cmd"]) { + const windowsCandidate = `${candidate}${extension}`; + if (input.existsSync(windowsCandidate)) { + return windowsCandidate; + } + } + } + } + return null; +} + +function resolveTargetDefinitions( + dependencies: ListEditorTargetsDependencies, +): readonly EditorTargetDefinition[] { + return dependencies.targetDefinitions ?? BUILT_IN_EDITOR_TARGETS; +} + +function findTarget( + targetId: string, + targetDefinitions: readonly EditorTargetDefinition[], +): EditorTargetDefinition { + const target = targetDefinitions.find((entry) => entry.id === targetId); + if (!target) { + throw new Error(`Unknown editor target: ${targetId}`); + } + return target; +} + +function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean { + return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value); +} + +function dirnameForPlatform(value: string, platform: NodeJS.Platform): string { + return platform === "win32" ? win32.dirname(value) : posix.dirname(value); +} + +function isWindowsCommandScript(executable: string, platform: NodeJS.Platform): boolean { + if (platform !== "win32") { + return false; + } + const extension = win32.extname(executable).toLowerCase(); + return extension === ".cmd" || extension === ".bat"; +} + +function escapeWindowsCmdValue(value: string): string { + const isQuoted = value.startsWith('"') && value.endsWith('"'); + const unquoted = isQuoted ? value.slice(1, -1) : value; + + if (isQuoted || /[\s"&|^<>()!]/u.test(unquoted)) { + const quoted = unquoted + .replace(/(\\*)"/g, (_match, slashes: string) => `${slashes}${slashes}\\"`) + .replace(/\\+$/u, (slashes) => `${slashes}${slashes}`); + return `"${quoted}"`; + } + + return unquoted; +} + +function createSpawnLaunch(launch: Launch, platform: NodeJS.Platform): SpawnLaunch { + if (!isWindowsCommandScript(launch.command, platform)) { + return { ...launch, shell: false }; + } + + return { + command: escapeWindowsCmdValue(launch.command), + args: launch.args.map(escapeWindowsCmdValue), + shell: true, + }; +} + +function buildLaunch(input: { + target: EditorTargetDefinition; + path: string; + cwd?: string; + mode: OpenEditorMode; + platform: NodeJS.Platform; + executable: string; +}): Launch { + if (input.mode === "reveal") { + if (input.target.id === "finder" && input.platform === "darwin") { + return { command: input.executable, args: ["-R", input.path] }; + } + if (input.target.id === "explorer" && input.platform === "win32") { + return { command: input.executable, args: ["/select,", input.path] }; + } + if (input.target.id === "file-manager") { + return { command: input.executable, args: [dirnameForPlatform(input.path, input.platform)] }; + } + } + + if (input.target.kind === "editor" && input.cwd && input.cwd !== input.path) { + return { command: input.executable, args: [input.cwd, input.path] }; + } + return { command: input.executable, args: [input.path] }; +} + +function spawnDetachedProcess( + command: string, + args: string[], + options: SpawnOptions, +): SpawnedProcess { + return nodeSpawn(command, args, options) as ChildProcess as SpawnedProcess; +} + +export function listAvailableEditorTargets( + dependencies: ListEditorTargetsDependencies = {}, +): DesktopEditorTargetDescriptor[] { + const platform = dependencies.platform ?? process.platform; + const existsSync = dependencies.existsSync ?? nodeExistsSync; + const env = dependencies.env ?? process.env; + + const targetDefinitions = resolveTargetDefinitions(dependencies); + + return targetDefinitions + .filter((target) => isTargetSupportedOnPlatform(target, platform)) + .filter((target) => resolveExecutable(target.command, { platform, env, existsSync })) + .map((target) => ({ id: target.id, label: target.label, kind: target.kind })); +} + +export async function openEditorTarget( + input: OpenEditorTargetInput, + dependencies: OpenEditorTargetDependencies = {}, +): Promise { + const parsedInput = OpenEditorTargetInputSchema.parse(input); + const platform = dependencies.platform ?? process.platform; + const existsSync = dependencies.existsSync ?? nodeExistsSync; + const env = dependencies.env ?? process.env; + const spawn = dependencies.spawn ?? spawnDetachedProcess; + const pathToOpen = parsedInput.path; + + if (!isAbsolutePath(pathToOpen, platform)) { + throw new Error("Editor target path must be an absolute local path"); + } + if (!existsSync(pathToOpen)) { + throw new Error(`Path does not exist: ${pathToOpen}`); + } + + const target = findTarget(parsedInput.editorId, resolveTargetDefinitions(dependencies)); + if (!isTargetSupportedOnPlatform(target, platform)) { + throw new Error(`Editor target unavailable: ${target.label}`); + } + + const executable = resolveExecutable(target.command, { platform, env, existsSync }); + if (!executable) { + throw new Error(`Editor target unavailable: ${target.label}`); + } + + const workspaceCwd = + parsedInput.cwd && + isAbsolutePath(parsedInput.cwd, platform) && + parsedInput.cwd !== pathToOpen && + existsSync(parsedInput.cwd) + ? parsedInput.cwd + : undefined; + const launch = buildLaunch({ + target, + path: pathToOpen, + cwd: workspaceCwd, + mode: parsedInput.mode ?? "open", + platform, + executable, + }); + const spawnLaunch = createSpawnLaunch(launch, platform); + + await new Promise((resolve, reject) => { + let child: SpawnedProcess; + try { + child = spawn(spawnLaunch.command, spawnLaunch.args, { + detached: true, + env: createExternalProcessEnv(env), + shell: spawnLaunch.shell, + stdio: "ignore", + }); + } catch (error) { + reject(error); + return; + } + + child.once("error", reject); + child.once("spawn", () => { + child.unref(); + resolve(); + }); + }); +} + +export function registerEditorTargetHandlers( + options: { + ipc?: IpcHandlerRegistry; + dependencies?: OpenEditorTargetDependencies; + } = {}, +): void { + const ipc = options.ipc ?? ipcMain; + const dependencies = options.dependencies ?? {}; + ipc.handle("paseo:editor:listTargets", () => listAvailableEditorTargets(dependencies)); + ipc.handle("paseo:editor:openTarget", async (_event, payload: unknown) => { + const parsedInput = OpenEditorTargetInputSchema.parse(payload); + await openEditorTarget(parsedInput, dependencies); + }); +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 3076ddb0f..42324bf03 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -43,6 +43,7 @@ import { ensureNotificationCenterRegistration, } from "./features/notifications.js"; import { registerOpenerHandlers } from "./features/opener.js"; +import { registerEditorTargetHandlers } from "./features/editor-targets.js"; import { setupApplicationMenu } from "./features/menu.js"; import { getPaseoBrowserIdForWebContents, @@ -697,6 +698,7 @@ async function bootstrap(): Promise { registerDialogHandlers(); registerNotificationHandlers(); registerOpenerHandlers(); + registerEditorTargetHandlers(); await createMainWindow(); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 6999fa9c2..fa2ec5e12 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -55,6 +55,15 @@ contextBridge.exposeInMainWorld("paseoDesktop", { opener: { openUrl: (url: string) => ipcRenderer.invoke("paseo:opener:openUrl", url), }, + editor: { + listTargets: () => ipcRenderer.invoke("paseo:editor:listTargets"), + openTarget: (input: { + editorId: string; + path: string; + cwd?: string; + mode?: "open" | "reveal"; + }) => ipcRenderer.invoke("paseo:editor:openTarget", input), + }, webUtils: { getPathForFile: (file: File) => webUtils.getPathForFile(file), }, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 2e679363b..8af157fee 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -145,7 +145,6 @@ export const MutableDaemonConfigPatchSchema = z export type MutableDaemonConfig = z.infer; export type MutableDaemonConfigPatch = z.infer; -import type { LiteralUnion } from "./literal-union.js"; import type { AgentCapabilityFlags, AgentModelDefinition, @@ -1569,47 +1568,18 @@ export const WorkspaceSetupStatusRequestSchema = z.object({ requestId: z.string(), }); -// TODO(2026-07): Remove once most clients are on >=0.1.50 and support arbitrary editor ids. -export const LEGACY_EDITOR_TARGET_IDS = [ - "cursor", - "vscode", - "zed", - "finder", - "explorer", - "file-manager", -] as const; - -export const KNOWN_EDITOR_TARGET_IDS = [...LEGACY_EDITOR_TARGET_IDS, "webstorm"] as const; - -export const KnownEditorTargetIdSchema = z.enum(KNOWN_EDITOR_TARGET_IDS); -export const LegacyEditorTargetIdSchema = z.enum(LEGACY_EDITOR_TARGET_IDS); -export const EditorTargetIdSchema = z.string().trim().min(1); - -const KNOWN_EDITOR_TARGET_ID_SET = new Set(KNOWN_EDITOR_TARGET_IDS); -const LEGACY_EDITOR_TARGET_ID_SET = new Set(LEGACY_EDITOR_TARGET_IDS); - -export function isKnownEditorTargetId(value: string): value is KnownEditorTargetId { - return KNOWN_EDITOR_TARGET_ID_SET.has(value); -} - -export function isLegacyEditorTargetId(value: string): value is LegacyEditorTargetId { - return LEGACY_EDITOR_TARGET_ID_SET.has(value); -} - -export const EditorTargetDescriptorPayloadSchema = z.object({ - id: EditorTargetIdSchema, - label: z.string(), -}); - -export const ListAvailableEditorsRequestSchema = z.object({ +// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs. +export const LegacyListAvailableEditorsRequestSchema = z.object({ type: z.literal("list_available_editors_request"), requestId: z.string(), }); -export const OpenInEditorRequestSchema = z.object({ +export const LegacyOpenInEditorRequestSchema = z.object({ type: z.literal("open_in_editor_request"), path: z.string(), - editorId: EditorTargetIdSchema, + editorId: z.string().trim().min(1), + mode: z.enum(["open", "reveal"]).optional(), + cwd: z.string().optional(), requestId: z.string(), }); @@ -1924,8 +1894,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeArchiveRequestSchema, CreatePaseoWorktreeRequestSchema, WorkspaceSetupStatusRequestSchema, - ListAvailableEditorsRequestSchema, - OpenInEditorRequestSchema, + LegacyListAvailableEditorsRequestSchema, + LegacyOpenInEditorRequestSchema, OpenProjectRequestSchema, ArchiveWorkspaceRequestSchema, FileExplorerRequestSchema, @@ -2596,16 +2566,22 @@ export const StartWorkspaceScriptResponseMessageSchema = z.object({ }), }); -export const ListAvailableEditorsResponseMessageSchema = z.object({ +// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer parse daemon editor RPC responses. +export const LegacyListAvailableEditorsResponseMessageSchema = z.object({ type: z.literal("list_available_editors_response"), payload: z.object({ requestId: z.string(), - editors: z.array(EditorTargetDescriptorPayloadSchema), + editors: z.array( + z.object({ + id: z.string().trim().min(1), + label: z.string(), + }), + ), error: z.string().nullable(), }), }); -export const OpenInEditorResponseMessageSchema = z.object({ +export const LegacyOpenInEditorResponseMessageSchema = z.object({ type: z.literal("open_in_editor_response"), payload: z.object({ requestId: z.string(), @@ -3684,8 +3660,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchWorkspacesResponseMessageSchema, OpenProjectResponseMessageSchema, StartWorkspaceScriptResponseMessageSchema, - ListAvailableEditorsResponseMessageSchema, - OpenInEditorResponseMessageSchema, + LegacyListAvailableEditorsResponseMessageSchema, + LegacyOpenInEditorResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, @@ -3811,10 +3787,6 @@ export type WorkspaceDescriptorPayload = z.infer; export type WorkspaceScriptHealth = z.infer; export type WorkspaceScriptPayload = z.infer; -export type KnownEditorTargetId = z.infer; -export type LegacyEditorTargetId = z.infer; -export type EditorTargetId = LiteralUnion; -export type EditorTargetDescriptorPayload = z.infer; export type FetchAgentsResponseMessage = z.infer; export type FetchAgentHistoryResponseMessage = z.infer< typeof FetchAgentHistoryResponseMessageSchema @@ -3828,10 +3800,12 @@ export type OpenProjectResponseMessage = z.infer; -export type ListAvailableEditorsResponseMessage = z.infer< - typeof ListAvailableEditorsResponseMessageSchema +export type LegacyListAvailableEditorsResponseMessage = z.infer< + typeof LegacyListAvailableEditorsResponseMessageSchema +>; +export type LegacyOpenInEditorResponseMessage = z.infer< + typeof LegacyOpenInEditorResponseMessageSchema >; -export type OpenInEditorResponseMessage = z.infer; export type ArchiveWorkspaceResponseMessage = z.infer; export type FetchAgentResponseMessage = z.infer; export type FetchAgentTimelineResponseMessage = z.infer< @@ -4030,8 +4004,10 @@ export type PaseoWorktreeListResponse = z.infer; export type PaseoWorktreeArchiveResponse = z.infer; export type WorkspaceSetupStatusRequest = z.infer; -export type ListAvailableEditorsRequest = z.infer; -export type OpenInEditorRequest = z.infer; +export type LegacyListAvailableEditorsRequest = z.infer< + typeof LegacyListAvailableEditorsRequestSchema +>; +export type LegacyOpenInEditorRequest = z.infer; export type OpenProjectRequest = z.infer; export type ArchiveWorkspaceRequest = z.infer; export type FileExplorerRequest = z.infer; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 3700d51f7..a74148325 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -241,62 +241,42 @@ describe("workspace message schemas", () => { expect(parsed.type).toBe("open_project_request"); }); - test("parses list_available_editors_request", () => { - const parsed = SessionInboundMessageSchema.parse({ + test("parses legacy editor RPC messages for compatibility", () => { + const listRequest = SessionInboundMessageSchema.parse({ type: "list_available_editors_request", requestId: "req-editors", }); - - expect(parsed.type).toBe("list_available_editors_request"); - }); - - test("parses open_in_editor_request with flexible editor ids", () => { - const knownEditor = SessionInboundMessageSchema.parse({ + const openRequest = SessionInboundMessageSchema.parse({ type: "open_in_editor_request", - requestId: "req-open-webstorm", - editorId: "webstorm", - path: "/tmp/repo", - }); - const unknownEditor = SessionInboundMessageSchema.parse({ - type: "open_in_editor_request", - requestId: "req-open-custom", + requestId: "req-open-editor", editorId: "unknown-editor", path: "/tmp/repo", + mode: "reveal", + cwd: "/tmp", }); - - expect(knownEditor.type).toBe("open_in_editor_request"); - expect(unknownEditor.type).toBe("open_in_editor_request"); - }); - - test("parses open_in_editor_response", () => { - const parsed = SessionOutboundMessageSchema.parse({ - type: "open_in_editor_response", - payload: { - requestId: "req-open-editor", - error: null, - }, - }); - - expect(parsed.type).toBe("open_in_editor_response"); - }); - - test("parses list_available_editors_response with unknown editor ids", () => { - const parsed = SessionOutboundMessageSchema.parse({ + const listResponse = SessionOutboundMessageSchema.parse({ type: "list_available_editors_response", payload: { requestId: "req-editors", - editors: [ - { id: "cursor", label: "Cursor" }, - { id: "unknown-editor", label: "Unknown Editor" }, - ], + editors: [{ id: "unknown-editor", label: "Unknown Editor" }], error: null, }, }); + const openResponse = SessionOutboundMessageSchema.parse({ + type: "open_in_editor_response", + payload: { + requestId: "req-open-editor", + error: "Editor opening moved to the desktop app", + }, + }); - expect(parsed.type).toBe("list_available_editors_response"); + expect(listRequest.type).toBe("list_available_editors_request"); + expect(openRequest.type).toBe("open_in_editor_request"); + expect(listResponse.type).toBe("list_available_editors_response"); + expect(openResponse.type).toBe("open_in_editor_response"); }); - test("rejects empty editor ids", () => { + test("rejects empty legacy editor ids", () => { const result = SessionInboundMessageSchema.safeParse({ type: "open_in_editor_request", requestId: "req-open-empty", diff --git a/packages/server/src/server/editor-targets.test.ts b/packages/server/src/server/editor-targets.test.ts deleted file mode 100644 index 11d8f4ead..000000000 --- a/packages/server/src/server/editor-targets.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { ChildProcess } from "node:child_process"; -import { describe, expect, it, vi } from "vitest"; -import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js"; - -describe("editor-targets", () => { - it("lists available editors in deterministic order", async () => { - const available = new Set(["code", "cursor", "explorer", "webstorm"]); - - const editors = await listAvailableEditorTargets({ - platform: "win32", - findExecutable: (command) => (available.has(command) ? command : null), - }); - - expect(editors).toEqual([ - { id: "cursor", label: "Cursor" }, - { id: "vscode", label: "VS Code" }, - { id: "webstorm", label: "WebStorm" }, - { id: "explorer", label: "Explorer" }, - ]); - }); - - it("returns Finder on macOS", async () => { - const editors = await listAvailableEditorTargets({ - platform: "darwin", - findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null), - }); - - expect(editors).toEqual([{ id: "finder", label: "Finder" }]); - }); - - it("returns the generic file manager target on Linux", async () => { - const editors = await listAvailableEditorTargets({ - platform: "linux", - findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null), - }); - - expect(editors).toEqual([{ id: "file-manager", label: "File Manager" }]); - }); - - it("launches editors as detached processes", async () => { - const unref = vi.fn(); - const once = vi.fn((event: string, handler: () => void) => { - if (event === "spawn") { - queueMicrotask(handler); - } - return child; - }); - const child = { once, unref }; - const spawn = vi.fn(() => child as unknown as ChildProcess); - - await openInEditorTarget( - { - editorId: "vscode", - path: "/tmp/repo", - }, - { - platform: "darwin", - existsSync: () => true, - findExecutable: (command) => (command === "code" ? "/usr/local/bin/code" : null), - spawn, - }, - ); - - expect(spawn).toHaveBeenCalledWith("/usr/local/bin/code", ["/tmp/repo"], { - detached: true, - env: expect.any(Object), - shell: false, - stdio: "ignore", - }); - expect(unref).toHaveBeenCalled(); - }); - - it("rejects relative paths", async () => { - await expect( - openInEditorTarget( - { - editorId: "cursor", - path: "repo", - }, - { - existsSync: () => true, - findExecutable: () => "/usr/local/bin/cursor", - }, - ), - ).rejects.toThrow("Editor target path must be an absolute local path"); - }); - - it("rejects platform-specific targets that are unavailable on this OS", async () => { - await expect( - openInEditorTarget( - { - editorId: "finder", - path: "/tmp/repo", - }, - { - platform: "linux", - existsSync: () => true, - findExecutable: () => "/usr/bin/open", - }, - ), - ).rejects.toThrow("Editor target unavailable: Finder"); - }); - - it("rejects unknown editor ids", async () => { - await expect( - openInEditorTarget( - { - editorId: "unknown-editor", - path: "/tmp/repo", - }, - { - existsSync: () => true, - findExecutable: () => null, - }, - ), - ).rejects.toThrow("Unknown editor target: unknown-editor"); - }); -}); diff --git a/packages/server/src/server/editor-targets.ts b/packages/server/src/server/editor-targets.ts deleted file mode 100644 index 16bfc4429..000000000 --- a/packages/server/src/server/editor-targets.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; -import { posix, win32 } from "node:path"; -import type { - EditorTargetDescriptorPayload, - EditorTargetId, - KnownEditorTargetId, -} from "@getpaseo/protocol/messages"; -import { createExternalProcessEnv } from "./paseo-env.js"; -import { findExecutable } from "../utils/executable.js"; -import { spawnProcess } from "../utils/spawn.js"; - -interface EditorTargetDefinition { - id: KnownEditorTargetId; - label: string; - command: string; - platforms?: readonly NodeJS.Platform[]; - excludedPlatforms?: readonly NodeJS.Platform[]; -} - -interface ListAvailableEditorTargetsDependencies { - platform?: NodeJS.Platform; - findExecutable?: (command: string) => string | null | Promise; -} - -type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & { - existsSync?: typeof existsSync; - spawn?: typeof spawnProcess; -}; - -const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [ - { id: "cursor", label: "Cursor", command: "cursor" }, - { id: "vscode", label: "VS Code", command: "code" }, - { id: "webstorm", label: "WebStorm", command: "webstorm" }, - { id: "zed", label: "Zed", command: "zed" }, - { id: "finder", label: "Finder", command: "open", platforms: ["darwin"] }, - { id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] }, - { - id: "file-manager", - label: "File Manager", - command: "xdg-open", - excludedPlatforms: ["darwin", "win32"], - }, -]; - -function isAbsolutePath(value: string): boolean { - return posix.isAbsolute(value) || win32.isAbsolute(value); -} - -function isTargetSupportedOnPlatform( - target: EditorTargetDefinition, - platform: NodeJS.Platform, -): boolean { - if (target.platforms && !target.platforms.includes(platform)) { - return false; - } - if (target.excludedPlatforms?.includes(platform)) { - return false; - } - return true; -} - -function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDefinition { - const target = EDITOR_TARGETS.find((entry) => entry.id === editorId); - if (!target) { - throw new Error(`Unknown editor target: ${editorId}`); - } - return target; -} - -export async function listAvailableEditorTargets( - dependencies: ListAvailableEditorTargetsDependencies = {}, -): Promise { - const platform = dependencies.platform ?? process.platform; - const findExecutableFn = dependencies.findExecutable ?? findExecutable; - - const supportedTargets = EDITOR_TARGETS.filter((target) => - isTargetSupportedOnPlatform(target, platform), - ); - const executables = await Promise.all( - supportedTargets.map((target) => findExecutableFn(target.command)), - ); - const results: EditorTargetDescriptorPayload[] = []; - for (let i = 0; i < supportedTargets.length; i += 1) { - if (!executables[i]) continue; - const target = supportedTargets[i]; - results.push({ - id: target.id, - label: target.label, - }); - } - return results; -} - -interface Launch { - command: string; - args: string[]; -} - -async function resolveEditorLaunch(input: { - editorId: EditorTargetId; - path: string; - platform: NodeJS.Platform; - findExecutableFn: (command: string) => string | null | Promise; -}): Promise { - const target = resolveEditorTargetDefinition(input.editorId); - if (!isTargetSupportedOnPlatform(target, input.platform)) { - throw new Error(`Editor target unavailable: ${target.label}`); - } - const executable = await input.findExecutableFn(target.command); - if (!executable) { - throw new Error(`Editor target unavailable: ${target.label}`); - } - - return { - command: executable, - args: [input.path], - }; -} - -export async function openInEditorTarget( - input: { - editorId: EditorTargetId; - path: string; - }, - dependencies: OpenInEditorTargetDependencies = {}, -): Promise { - const platform = dependencies.platform ?? process.platform; - const pathToOpen = input.path.trim(); - const existsSyncFn = dependencies.existsSync ?? existsSync; - const findExecutableFn = dependencies.findExecutable ?? findExecutable; - const spawnFn = dependencies.spawn ?? spawnProcess; - - if (!pathToOpen || !isAbsolutePath(pathToOpen)) { - throw new Error("Editor target path must be an absolute local path"); - } - if (!existsSyncFn(pathToOpen)) { - throw new Error(`Path does not exist: ${pathToOpen}`); - } - - const launch = await resolveEditorLaunch({ - editorId: input.editorId, - path: pathToOpen, - platform, - findExecutableFn, - }); - - await new Promise((resolve, reject) => { - let child: ChildProcess; - try { - child = spawnFn(launch.command, launch.args, { - detached: true, - env: createExternalProcessEnv(process.env), - shell: platform === "win32", - stdio: "ignore", - }); - } catch (error) { - reject(error); - return; - } - - child.once("error", reject); - child.once("spawn", () => { - child.unref(); - resolve(); - }); - }); -} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9c7e2b7eb..302d98ccf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,5 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; -import { TTLCache } from "@isaacs/ttlcache"; -import pMemoize from "p-memoize"; import { realpathSync } from "node:fs"; import type { FSWatcher } from "node:fs"; import { basename, resolve, sep } from "path"; @@ -10,7 +8,6 @@ import { z } from "zod"; import type { ToolSet } from "ai"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import { - isLegacyEditorTargetId, serializeAgentStreamEvent, type AgentSnapshotPayload, type AgentAttachment, @@ -26,8 +23,6 @@ import { type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, - type EditorTargetDescriptorPayload, - type EditorTargetId, type ProjectPlacementPayload, type WorkspaceSetupSnapshot, type WorkspaceDescriptorPayload, @@ -47,7 +42,6 @@ import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"; import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js"; import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js"; -import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js"; import { getPidLockInfo } from "./pid-lock.js"; import { generateLocalPairingOffer } from "./pairing-offer.js"; import { @@ -337,7 +331,6 @@ const LEGACY_MODE_ICONS = new Set([ "ShieldQuestionMark", ]); const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; -const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50"; function errorToFriendlyMessage(error: unknown): string { if (error instanceof Error) return error.message; @@ -418,10 +411,6 @@ function clientSupportsAllProviders(appVersion: string | null): boolean { return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS); } -function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { - return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS); -} - type DeleteFencedAgentStorage = AgentStorage & { beginDelete(agentId: string): void; }; @@ -529,9 +518,6 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS, ); const AgentIdSchema = z.string().uuid(); -const AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS = 60_000; -const AVAILABLE_EDITOR_TARGETS_CACHE_KEY = "available"; - interface VoiceModeBaseConfig { systemPrompt?: string; } @@ -817,21 +803,6 @@ export class Session { private readonly terminalController: TerminalSessionController; private inflightRequests = 0; private peakInflightRequests = 0; - private readonly availableEditorTargetsCache = new TTLCache< - string, - EditorTargetDescriptorPayload[] - >({ - ttl: AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS, - max: 1, - checkAgeOnGet: true, - }); - private readonly getMemoizedAvailableEditorTargets = pMemoize( - async () => this.resolveAvailableEditorTargets(), - { - cache: this.availableEditorTargetsCache, - cacheKey: () => AVAILABLE_EDITOR_TARGETS_CACHE_KEY, - }, - ); private readonly checkoutDiffSubscriptions = new Map void>(); private readonly workspaceGitWatchTargets = new Map(); private readonly workspaceSetupSnapshots: Map; @@ -1446,15 +1417,6 @@ export class Session { return LEGACY_PROVIDER_IDS.has(provider); } - private filterEditorsForClient( - editors: EditorTargetDescriptorPayload[], - ): EditorTargetDescriptorPayload[] { - if (clientSupportsFlexibleEditorIds(this.appVersion)) { - return editors; - } - return editors.filter((editor) => isLegacyEditorTargetId(editor.id)); - } - private agentThinkingOptionMatchesFilter( agent: AgentSnapshotPayload, filter: AgentUpdatesFilter, @@ -2128,10 +2090,11 @@ export class Session { return this.handleCreatePaseoWorktreeRequest(msg); case "workspace_setup_status_request": return this.handleWorkspaceSetupStatusRequest(msg); + // COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs. case "list_available_editors_request": - return this.handleListAvailableEditorsRequest(msg); + return this.handleLegacyListAvailableEditorsRequest(msg); case "open_in_editor_request": - return this.handleOpenInEditorRequest(msg); + return this.handleLegacyOpenInEditorRequest(msg); case "open_project_request": return this.handleOpenProjectRequest(msg); case "archive_workspace_request": @@ -7166,18 +7129,6 @@ export class Session { }); } - async resolveAvailableEditorTargets(): Promise { - return listAvailableEditorTargets(); - } - - async getAvailableEditorTargets() { - return this.filterEditorsForClient(await this.getMemoizedAvailableEditorTargets()); - } - - async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise { - await openInEditorTarget(options); - } - private async handleStartWorkspaceScriptRequest( request: StartWorkspaceScriptRequest, ): Promise { @@ -7244,67 +7195,30 @@ export class Session { } } - private async handleListAvailableEditorsRequest( + // COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs. + private async handleLegacyListAvailableEditorsRequest( request: Extract, ): Promise { - try { - const editors = await this.getAvailableEditorTargets(); - this.emit({ - type: "list_available_editors_response", - payload: { - requestId: request.requestId, - editors, - error: null, - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to list available editors"; - this.sessionLogger.error( - { err: error, requestType: request.type }, - "Failed to list available editors", - ); - this.emit({ - type: "list_available_editors_response", - payload: { - requestId: request.requestId, - editors: [], - error: message, - }, - }); - } + this.emit({ + type: "list_available_editors_response", + payload: { + requestId: request.requestId, + editors: [], + error: "Editor opening moved to the desktop app and is no longer supported by the daemon", + }, + }); } - private async handleOpenInEditorRequest( + private async handleLegacyOpenInEditorRequest( request: Extract, ): Promise { - try { - await this.openEditorTarget({ editorId: request.editorId, path: request.path }); - this.emit({ - type: "open_in_editor_response", - payload: { - requestId: request.requestId, - error: null, - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to open in editor"; - this.sessionLogger.error( - { - err: error, - editorId: request.editorId, - path: request.path, - requestType: request.type, - }, - "Failed to open in editor", - ); - this.emit({ - type: "open_in_editor_response", - payload: { - requestId: request.requestId, - error: message, - }, - }); - } + this.emit({ + type: "open_in_editor_response", + payload: { + requestId: request.requestId, + error: "Editor opening moved to the desktop app and is no longer supported by the daemon", + }, + }); } private async handleCreatePaseoWorktreeRequest( diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 84dc8d851..172a74245 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -5,11 +5,7 @@ import path from "node:path"; import { expect, test, vi } from "vitest"; import { z } from "zod"; import { Session } from "./session.js"; -import type { - AgentSnapshotPayload, - EditorTargetDescriptorPayload, - SessionOutboundMessage, -} from "@getpaseo/protocol/messages"; +import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage } from "./agent/agent-storage.js"; import type { @@ -123,10 +119,6 @@ interface SessionTestAccess { emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; emit(message: unknown): void; onMessage(message: unknown): void; - getAvailableEditorTargets(...args: unknown[]): Promise; - filterEditorsForClient(...args: unknown[]): unknown; - openEditorTarget(input: { editorId: string; path: string }): Promise; - resolveAvailableEditorTargets(...args: unknown[]): Promise; paseoHome: string; terminalManager: { killTerminal(id: string): unknown; @@ -3492,137 +3484,16 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root expect(response?.payload.workspace?.id).toBe(repoRoot); }); -test("list_available_editors_request returns available targets", async () => { +test("legacy editor RPC requests return daemon unsupported errors", async () => { const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" }); - - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.getAvailableEditorTargets = async () => - session.filterEditorsForClient([ - { id: "cursor", label: "Cursor" }, - { id: "webstorm", label: "WebStorm" }, - { id: "finder", label: "Finder" }, - { id: "unknown-editor", label: "Unknown Editor" }, - ]); + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + }); await session.handleMessage({ type: "list_available_editors_request", requestId: "req-editors", }); - - const response = emitted.find((message) => message.type === "list_available_editors_response") as - | { payload: Record } - | undefined; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.editors).toEqual([ - { id: "cursor", label: "Cursor" }, - { id: "webstorm", label: "WebStorm" }, - { id: "finder", label: "Finder" }, - { id: "unknown-editor", label: "Unknown Editor" }, - ]); -}); - -test("list_available_editors_request coalesces concurrent discovery", async () => { - const emitted: SessionOutboundMessage[] = []; - const session = asTestSession( - createSessionForWorkspaceTests({ - appVersion: "0.1.50", - onMessage: (message) => emitted.push(message), - }), - ); - let resolveDiscovery: (editors: EditorTargetDescriptorPayload[]) => void = () => {}; - let discoveryCalls = 0; - - vi.spyOn(session, "resolveAvailableEditorTargets").mockImplementation(async () => { - discoveryCalls += 1; - return await new Promise((resolve) => { - resolveDiscovery = resolve; - }); - }); - - const first = session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-1", - }); - const second = session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-2", - }); - - await Promise.resolve(); - expect(discoveryCalls).toBe(1); - - resolveDiscovery([{ id: "cursor", label: "Cursor" }]); - await Promise.all([first, second]); - - const responses = emitted.filter( - ( - message, - ): message is Extract => - message.type === "list_available_editors_response", - ); - expect(responses).toHaveLength(2); - expect(responses.map((response) => response.payload.requestId)).toEqual([ - "req-editors-1", - "req-editors-2", - ]); - expect(responses.map((response) => response.payload.editors)).toEqual([ - [{ id: "cursor", label: "Cursor" }], - [{ id: "cursor", label: "Cursor" }], - ]); - - await session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-3", - }); - - expect(discoveryCalls).toBe(1); -}); - -test("list_available_editors_request filters unsupported ids for legacy clients", async () => { - const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests({ appVersion: "0.1.49" }); - - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.getAvailableEditorTargets = async () => - session.filterEditorsForClient([ - { id: "cursor", label: "Cursor" }, - { id: "webstorm", label: "WebStorm" }, - { id: "unknown-editor", label: "Unknown Editor" }, - { id: "finder", label: "Finder" }, - ]); - - await session.handleMessage({ - type: "list_available_editors_request", - requestId: "req-editors-legacy", - }); - - const response = emitted.find((message) => message.type === "list_available_editors_response") as - | { payload: Record } - | undefined; - expect(response?.payload.error).toBeNull(); - expect(response?.payload.editors).toEqual([ - { id: "cursor", label: "Cursor" }, - { id: "finder", label: "Finder" }, - ]); -}); - -test("open_in_editor_request launches the selected target", async () => { - const emitted: SessionOutboundMessage[] = []; - const session = createSessionForWorkspaceTests(); - const calls: Array<{ editorId: string; path: string }> = []; - - session.emit = (message) => { - if (isSessionOutboundMessage(message)) emitted.push(message); - }; - session.openEditorTarget = async (input: { editorId: string; path: string }) => { - calls.push(input); - }; - await session.handleMessage({ type: "open_in_editor_request", requestId: "req-open-editor", @@ -3630,11 +3501,15 @@ test("open_in_editor_request launches the selected target", async () => { path: REPO_CWD, }); - expect(calls).toEqual([{ editorId: "vscode", path: REPO_CWD }]); - const response = emitted.find((message) => message.type === "open_in_editor_response") as - | { payload: Record } - | undefined; - expect(response?.payload.error).toBeNull(); + const listResponse = findByType(emitted, "list_available_editors_response"); + const openResponse = findByType(emitted, "open_in_editor_response"); + expect(listResponse?.payload.editors).toEqual([]); + expect(listResponse?.payload.error).toBe( + "Editor opening moved to the desktop app and is no longer supported by the daemon", + ); + expect(openResponse?.payload.error).toBe( + "Editor opening moved to the desktop app and is no longer supported by the daemon", + ); }); test("archive_workspace_request hides non-destructive workspace records", async () => { From c0a0e3a1c62509eb7eacf857f7f5ac7b10aa3229 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 19:09:02 +0800 Subject: [PATCH 2/4] Keep desktop skills up to date automatically (#1309) * Keep installed desktop skills up to date * Preserve user files during skill sync --- .../components/integrations-section.tsx | 30 +----- packages/desktop/src/desktop-startup.test.ts | 18 ++++ packages/desktop/src/desktop-startup.ts | 2 + .../desktop/src/integrations/skills/index.ts | 1 + .../integrations/skills/operations.test.ts | 101 +++++++++++++++++- .../src/integrations/skills/operations.ts | 56 +++++++--- .../src/integrations/skills/sync.test.ts | 49 +++++++-- .../desktop/src/integrations/skills/sync.ts | 87 ++++++++++++++- packages/desktop/src/main.ts | 6 ++ public-docs/skills.md | 2 + 10 files changed, 297 insertions(+), 55 deletions(-) diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index 4fcddef99..2b66f357b 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -8,11 +8,7 @@ import { SettingsSection } from "@/screens/settings/settings-section"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { - shouldUseDesktopDaemon, - type SkillOp, - type SkillsStatus, -} from "@/desktop/daemon/desktop-daemon"; +import { shouldUseDesktopDaemon, type SkillsStatus } from "@/desktop/daemon/desktop-daemon"; import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status"; const CLI_DOCS_URL = "https://paseo.sh/docs/cli"; @@ -21,21 +17,6 @@ const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const UNINSTALL_MESSAGE = "Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex."; -const OP_KIND_ORDER: Record = { add: 0, update: 1, delete: 2 }; -const OP_KIND_LABEL: Record = { - add: "Add skill", - update: "Update skill", - delete: "Delete skill", -}; - -function formatUpdateMessage(ops: readonly SkillOp[]): string { - const sorted = [...ops].sort((a, b) => { - const kindOrder = OP_KIND_ORDER[a.kind] - OP_KIND_ORDER[b.kind]; - return kindOrder !== 0 ? kindOrder : a.name.localeCompare(b.name); - }); - return sorted.map((op) => `${OP_KIND_LABEL[op.kind]} ${op.name}`).join("\n"); -} - export function IntegrationsSection() { const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); @@ -75,15 +56,8 @@ export function IntegrationsSection() { const handleUpdateSkills = useCallback(async () => { if (isSkillsWorking) return; - const ops = skillsStatus?.ops ?? []; - const confirmed = await confirmDialog({ - title: "Update Paseo skills?", - message: ops.length > 0 ? formatUpdateMessage(ops) : "Sync bundled skills to your machine.", - confirmLabel: "Update", - }); - if (!confirmed) return; await updateSkills(); - }, [isSkillsWorking, skillsStatus, updateSkills]); + }, [isSkillsWorking, updateSkills]); const handleUninstallSkills = useCallback(async () => { if (isSkillsWorking) return; diff --git a/packages/desktop/src/desktop-startup.test.ts b/packages/desktop/src/desktop-startup.test.ts index c766a22a7..c447c216d 100644 --- a/packages/desktop/src/desktop-startup.test.ts +++ b/packages/desktop/src/desktop-startup.test.ts @@ -36,6 +36,24 @@ describe("desktop startup", () => { expect(calls).toEqual(["cli", "env", "gui"]); }); + it("starts skills auto-update after GUI startup", async () => { + const calls: string[] = []; + await runDesktopStartup({ + hasPendingOpenProjectPath: false, + runCliPassthroughIfRequested: vi.fn(async () => { + calls.push("cli"); + return false; + }), + inheritLoginShellEnv: vi.fn(() => calls.push("env")), + bootstrapGui: vi.fn(async () => { + calls.push("gui"); + }), + autoUpdateInstalledSkills: vi.fn(() => calls.push("skills")), + }); + + expect(calls).toEqual(["cli", "env", "gui", "skills"]); + }); + it("does not route open-project launches through CLI passthrough", async () => { const runCliPassthroughIfRequested = vi.fn(async () => true); const calls: string[] = []; diff --git a/packages/desktop/src/desktop-startup.ts b/packages/desktop/src/desktop-startup.ts index 6ee8a0f0f..25943e342 100644 --- a/packages/desktop/src/desktop-startup.ts +++ b/packages/desktop/src/desktop-startup.ts @@ -3,6 +3,7 @@ export interface DesktopStartupDependencies { runCliPassthroughIfRequested: () => Promise; inheritLoginShellEnv: () => void; bootstrapGui: () => Promise; + autoUpdateInstalledSkills?: () => void; } export async function runDesktopStartup(deps: DesktopStartupDependencies): Promise { @@ -12,4 +13,5 @@ export async function runDesktopStartup(deps: DesktopStartupDependencies): Promi deps.inheritLoginShellEnv(); await deps.bootstrapGui(); + deps.autoUpdateInstalledSkills?.(); } diff --git a/packages/desktop/src/integrations/skills/index.ts b/packages/desktop/src/integrations/skills/index.ts index 5020b97f0..d568e7436 100644 --- a/packages/desktop/src/integrations/skills/index.ts +++ b/packages/desktop/src/integrations/skills/index.ts @@ -1,4 +1,5 @@ export { + autoUpdateInstalledSkills, getSkillsStatus, installSkills, uninstallSkills, diff --git a/packages/desktop/src/integrations/skills/operations.test.ts b/packages/desktop/src/integrations/skills/operations.test.ts index ee8617492..a31a24266 100644 --- a/packages/desktop/src/integrations/skills/operations.test.ts +++ b/packages/desktop/src/integrations/skills/operations.test.ts @@ -11,6 +11,7 @@ vi.mock("electron", () => ({ })); import { + autoUpdateInstalledSkills, getSkillsStatus, installSkills, PASEO_SKILL_NAMES, @@ -60,6 +61,18 @@ async function writeOnDiskSkill( await writeFiles(path.join(agentsDir, name), files); } +async function writeOnDiskSkillToAllTargets( + targets: SkillTargets, + name: string, + files: Record, +): Promise { + await Promise.all([ + writeOnDiskSkill(targets.agentsDir, name, files), + writeOnDiskSkill(targets.claudeDir, name, files), + writeOnDiskSkill(targets.codexDir, name, files), + ]); +} + async function writeCurrentBundle(sourceDir: string): Promise { await writeBundleSkill(sourceDir, "paseo", { "SKILL.md": "paseo-v1" }); await writeBundleSkill(sourceDir, "paseo-loop", { "SKILL.md": "loop-v1" }); @@ -112,8 +125,22 @@ describe("getSkillsStatus", () => { it("returns up-to-date when every bundled skill matches on disk", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); - await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); - await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" }); + await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" }); + + const status = await getSkillsStatus(sandbox.targets); + + expect(status).toEqual({ state: "up-to-date", ops: [] }); + }); + + it("ignores user-added files inside current managed skill dirs", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" }); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { + "SKILL.md": "paseo-v1", + "my-context.md": "user context", + }); const status = await getSkillsStatus(sandbox.targets); @@ -123,7 +150,24 @@ describe("getSkillsStatus", () => { it("returns drift with a single update op when one bundled file diverges", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); + await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" }); + + const status = await getSkillsStatus(sandbox.targets); + + expect(status.state).toBe("drift"); + expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]); + }); + + it("returns drift when a secondary agent target is stale", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" }); + await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "stale" }); + await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-loop", { "SKILL.md": "loop-v1" }); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" }); const status = await getSkillsStatus(sandbox.targets); @@ -133,7 +177,7 @@ describe("getSkillsStatus", () => { it("returns drift with add ops for the bundled skills missing from disk", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); - await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" }); const status = await getSkillsStatus(sandbox.targets); @@ -143,8 +187,8 @@ describe("getSkillsStatus", () => { it("returns drift with a delete op for a legacy skill name still on disk", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); - await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); - await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" }); + await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-loop", { "SKILL.md": "loop-v1" }); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" }); const status = await getSkillsStatus(sandbox.targets); @@ -156,6 +200,8 @@ describe("getSkillsStatus", () => { it("emits add + update + delete ops sorted by name when state is mixed", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); + await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" }); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" }); const status = await getSkillsStatus(sandbox.targets); @@ -226,6 +272,51 @@ describe("installSkills / updateSkills", () => { } }); + it("repairs secondary agent targets even when agents skills are current", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-loop", { "SKILL.md": "loop-v1" }); + await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo", { "SKILL.md": "paseo-v1" }); + await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-loop", { "SKILL.md": "loop-v1" }); + + const status = await updateSkills(sandbox.targets); + + expect(status).toEqual({ state: "up-to-date", ops: [] }); + expect( + await fs.readFile(path.join(sandbox.targets.claudeDir, "paseo-loop", "SKILL.md"), "utf-8"), + ).toBe("loop-v1"); + }); + + it("auto-updates drifted installed skills", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); + + const status = await autoUpdateInstalledSkills(sandbox.targets); + + expect(status).toEqual({ state: "up-to-date", ops: [] }); + expect( + await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"), + ).toBe("paseo-v1"); + }); + + it("does not auto-install skills on a clean machine", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + + const status = await autoUpdateInstalledSkills(sandbox.targets); + + expect(status).toEqual({ + state: "not-installed", + ops: [ + { kind: "add", name: "paseo" }, + { kind: "add", name: "paseo-loop" }, + ], + }); + expect(await pathExists(path.join(sandbox.targets.agentsDir, "paseo"))).toBe(false); + expect(await pathExists(path.join(sandbox.targets.claudeDir, "paseo"))).toBe(false); + expect(await pathExists(path.join(sandbox.targets.codexDir, "paseo"))).toBe(false); + }); + it("is idempotent — running install twice keeps state at up-to-date", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); diff --git a/packages/desktop/src/integrations/skills/operations.ts b/packages/desktop/src/integrations/skills/operations.ts index 8acaa17b7..35fd6f060 100644 --- a/packages/desktop/src/integrations/skills/operations.ts +++ b/packages/desktop/src/integrations/skills/operations.ts @@ -41,6 +41,7 @@ export const PASEO_SKILL_NAMES = [ ] as const; type SkillFiles = Map; +type TargetSkills = Map; function resolveSkillTargets(): SkillTargets { return { @@ -74,23 +75,34 @@ async function hashSkills(rootDir: string): Promise> { return out; } -function diff(bundle: Map, disk: Map): SkillOp[] { +function diff(bundle: TargetSkills, disks: readonly TargetSkills[]): SkillOp[] { const ops: SkillOp[] = []; for (const name of PASEO_SKILL_NAMES) { const b = bundle.get(name); - const d = disk.get(name); - if (b && !d) ops.push({ kind: "add", name }); - else if (b && d && !filesEqual(b, d)) ops.push({ kind: "update", name }); - else if (!b && d) ops.push({ kind: "delete", name }); + const targetFiles = disks.map((disk) => disk.get(name)); + const installedTargets = targetFiles.filter( + (files): files is SkillFiles => files !== undefined, + ); + if (b) { + const missingTargets = installedTargets.length < disks.length; + const changedTargets = installedTargets.some((files) => !bundleFilesMatch(b, files)); + if (missingTargets) ops.push({ kind: "add", name }); + else if (changedTargets) ops.push({ kind: "update", name }); + } else if (installedTargets.length > 0) { + ops.push({ kind: "delete", name }); + } } ops.sort((a, b) => compareStrings(a.name, b.name)); return ops; } -function filesEqual(a: SkillFiles, b: SkillFiles): boolean { - if (a.size !== b.size) return false; - for (const [rel, sha] of a) { - if (b.get(rel) !== sha) return false; +function hasInstalledPaseoSkill(disks: readonly TargetSkills[]): boolean { + return disks.some((disk) => disk.size > 0); +} + +function bundleFilesMatch(bundle: SkillFiles, disk: SkillFiles): boolean { + for (const [rel, sha] of bundle) { + if (disk.get(rel) !== sha) return false; } return true; } @@ -107,16 +119,25 @@ function compareStrings(a: string, b: string): number { export async function getSkillsStatus(targets?: SkillTargets): Promise { const t = targets ?? resolveSkillTargets(); - const [bundle, disk] = await Promise.all([hashSkills(t.sourceDir), hashSkills(t.agentsDir)]); - const ops = diff(bundle, disk); + const [bundle, agentsDisk, claudeDisk, codexDisk] = await Promise.all([ + hashSkills(t.sourceDir), + hashSkills(t.agentsDir), + hashSkills(t.claudeDir), + hashSkills(t.codexDir), + ]); + const disks = [agentsDisk, claudeDisk, codexDisk]; + const ops = diff(bundle, disks); - if (disk.size === 0) return { state: "not-installed", ops }; + if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops }; if (ops.length === 0) return { state: "up-to-date", ops }; return { state: "drift", ops }; } -async function applySkills(targets: SkillTargets): Promise { - const status = await getSkillsStatus(targets); +async function applySkills( + targets: SkillTargets, + initialStatus?: SkillsStatus, +): Promise { + const status = initialStatus ?? (await getSkillsStatus(targets)); const writes = status.ops .filter((op) => op.kind === "add" || op.kind === "update") @@ -151,6 +172,13 @@ export async function updateSkills(targets?: SkillTargets): Promise { + const t = targets ?? resolveSkillTargets(); + const status = await getSkillsStatus(t); + if (status.state !== "drift") return status; + return applySkills(t, status); +} + export async function uninstallSkills(targets?: SkillTargets): Promise { const t = targets ?? resolveSkillTargets(); for (const name of PASEO_SKILL_NAMES) { diff --git a/packages/desktop/src/integrations/skills/sync.test.ts b/packages/desktop/src/integrations/skills/sync.test.ts index 52c7bbbcc..0bd7e4915 100644 --- a/packages/desktop/src/integrations/skills/sync.test.ts +++ b/packages/desktop/src/integrations/skills/sync.test.ts @@ -137,12 +137,23 @@ describe("syncSkills", () => { expect(await fs.readFile(path.join(customSkill, "SKILL.md"), "utf-8")).toBe("user content"); }); - it("does not delete files on disk that are no longer in the bundle", async () => { - await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" }); + it("removes stale files previously written to managed skill dirs", async () => { + await writeBundleSkill(sandbox.sourceDir, "paseo", { + "SKILL.md": "old", + "references/stale.md": "stale", + }); const onDiskSkill = path.join(sandbox.agentsDir, "paseo"); - await fs.mkdir(path.join(onDiskSkill, "references"), { recursive: true }); - await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old"); - await fs.writeFile(path.join(onDiskSkill, "references", "stale.md"), "stale"); + + await syncSkills({ + sourceDir: sandbox.sourceDir, + agentsDir: sandbox.agentsDir, + claudeDir: sandbox.claudeDir, + codexDir: sandbox.codexDir, + skillNames: ["paseo"], + }); + + await fs.rm(path.join(sandbox.sourceDir, "paseo"), { recursive: true, force: true }); + await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" }); await syncSkills({ sourceDir: sandbox.sourceDir, @@ -153,8 +164,32 @@ describe("syncSkills", () => { }); expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new"); - expect(await fs.readFile(path.join(onDiskSkill, "references", "stale.md"), "utf-8")).toBe( - "stale", + await expect(fs.access(path.join(onDiskSkill, "references", "stale.md"))).rejects.toThrow(); + await expect(fs.access(path.join(onDiskSkill, "references"))).rejects.toThrow(); + }); + + it("preserves user-added files in managed skill dirs", async () => { + await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "new" }); + const onDiskSkill = path.join(sandbox.agentsDir, "paseo"); + await fs.mkdir(path.join(onDiskSkill, "references"), { recursive: true }); + await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old"); + await fs.writeFile(path.join(onDiskSkill, "my-context.md"), "user context"); + await fs.writeFile(path.join(onDiskSkill, "references", "notes.md"), "user notes"); + + await syncSkills({ + sourceDir: sandbox.sourceDir, + agentsDir: sandbox.agentsDir, + claudeDir: sandbox.claudeDir, + codexDir: sandbox.codexDir, + skillNames: ["paseo"], + }); + + expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("new"); + expect(await fs.readFile(path.join(onDiskSkill, "my-context.md"), "utf-8")).toBe( + "user context", + ); + expect(await fs.readFile(path.join(onDiskSkill, "references", "notes.md"), "utf-8")).toBe( + "user notes", ); }); diff --git a/packages/desktop/src/integrations/skills/sync.ts b/packages/desktop/src/integrations/skills/sync.ts index 8004b3a79..4741101ed 100644 --- a/packages/desktop/src/integrations/skills/sync.ts +++ b/packages/desktop/src/integrations/skills/sync.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; @@ -15,6 +16,13 @@ export interface SkillSyncResult { processedSkills: number; } +const MANAGED_FILES_MANIFEST = ".paseo-managed-files.json"; + +interface ManagedFilesManifest { + version: 1; + files: Record; +} + async function writeFileIfChanged(srcPath: string, dstPath: string): Promise { const src = await fs.readFile(srcPath); const dst = await fs.readFile(dstPath).catch(() => null); @@ -30,10 +38,12 @@ export async function listFilesRecursive(rootDir: string): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const full = path.join(dir, entry.name); + const rel = path.relative(rootDir, full); + if (rel === MANAGED_FILES_MANIFEST) continue; if (entry.isDirectory()) { await walk(full); } else if (entry.isFile()) { - out.push(path.relative(rootDir, full)); + out.push(rel); } } } @@ -41,14 +51,89 @@ export async function listFilesRecursive(rootDir: string): Promise { return out; } +async function readManagedFilesManifest(dstDir: string): Promise { + const raw = await fs + .readFile(path.join(dstDir, MANAGED_FILES_MANIFEST), "utf-8") + .catch(() => null); + if (!raw) return null; + const parsed = safeParseJson(raw) as Partial | null; + if (!parsed) return null; + if (parsed.version !== 1 || typeof parsed.files !== "object" || parsed.files === null) + return null; + return { version: 1, files: parsed.files as Record }; +} + +function safeParseJson(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +async function hashFile(filePath: string): Promise { + const buf = await fs.readFile(filePath); + return createHash("sha256").update(buf).digest("hex"); +} + +async function writeManifestIfChanged( + dstDir: string, + files: Record, +): Promise { + const manifest: ManagedFilesManifest = { version: 1, files }; + const next = `${JSON.stringify(manifest, null, 2)}\n`; + const manifestPath = path.join(dstDir, MANAGED_FILES_MANIFEST); + const current = await fs.readFile(manifestPath, "utf-8").catch(() => null); + if (current === next) return false; + await fs.mkdir(dstDir, { recursive: true }); + await fs.writeFile(manifestPath, next); + return true; +} + +async function pruneEmptyParentDirs(rootDir: string, rels: readonly string[]): Promise { + const dirs = new Set(); + for (const rel of rels) { + let dir = path.dirname(rel); + while (dir !== ".") { + dirs.add(dir); + dir = path.dirname(dir); + } + } + const deepestFirst = [...dirs].sort( + (a, b) => b.split(path.sep).length - a.split(path.sep).length, + ); + for (const rel of deepestFirst) { + await fs.rmdir(path.join(rootDir, rel)).catch(() => {}); + } +} + async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise { const files = await listFilesRecursive(srcDir); + const srcFileSet = new Set(files); + const srcHashes: Record = {}; + for (const rel of files) { + srcHashes[rel] = await hashFile(path.join(srcDir, rel)); + } + const previousManifest = await readManagedFilesManifest(dstDir); let changed = 0; for (const rel of files) { if (await writeFileIfChanged(path.join(srcDir, rel), path.join(dstDir, rel))) { changed++; } } + + const deletedRels: string[] = []; + for (const [rel, previousHash] of Object.entries(previousManifest?.files ?? {})) { + if (srcFileSet.has(rel)) continue; + const dstPath = path.join(dstDir, rel); + const currentHash = await hashFile(dstPath).catch(() => null); + if (currentHash !== previousHash) continue; + await fs.rm(dstPath, { force: true }); + deletedRels.push(rel); + changed++; + } + await pruneEmptyParentDirs(dstDir, deletedRels); + if (await writeManifestIfChanged(dstDir, srcHashes)) changed++; return changed; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 42324bf03..01e4883da 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -64,6 +64,7 @@ import { stopDesktopManagedDaemonOnQuitIfNeeded, } from "./daemon/quit-lifecycle.js"; import { runDesktopStartup } from "./desktop-startup.js"; +import { autoUpdateInstalledSkills } from "./integrations/skills/index.js"; const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081"; const APP_SCHEME = "paseo"; @@ -714,6 +715,11 @@ void runDesktopStartup({ runCliPassthroughIfRequested, inheritLoginShellEnv, bootstrapGui: bootstrap, + autoUpdateInstalledSkills: () => { + void autoUpdateInstalledSkills().catch((error) => { + log.error("[skills] auto-update failed", error); + }); + }, }).catch((error) => { const message = error instanceof Error ? (error.stack ?? error.message) : String(error); process.stderr.write(`${message}\n`); diff --git a/public-docs/skills.md b/public-docs/skills.md index 99518d6d7..f1b5a0755 100644 --- a/public-docs/skills.md +++ b/public-docs/skills.md @@ -16,6 +16,8 @@ Two ways to install: - **Desktop app:** Settings → Integrations → Install - **Manual:** `npx skills add getpaseo/paseo`, this installs to `~/.agents/skills/` and sets up symlinks for each agent. +When the desktop app finds installed Paseo skills, it keeps the bundled skills up to date on startup. If automatic update fails, use Settings → Integrations → Update or the manual command above. + ## `/paseo`, Paseo Reference The foundational skill. Paseo reference for managing agents and worktrees. Load it when an agent needs to create agents, send them prompts, or manage worktrees. From e9a746d4ea370132a34d0b06c02f0ae81e293bd5 Mon Sep 17 00:00:00 2001 From: Aaron Zhong Date: Wed, 3 Jun 2026 23:20:30 +1200 Subject: [PATCH 3/4] feat: open active files in editors and file managers (#1285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: open active files in editors and file managers * fix: open the workspace folder alongside the active file in editors Editor targets received only the file path, so opening from the workspace file explorer launched the bare file without the surrounding project. Thread an optional workspace `cwd` through the open_in_editor request so editors launch as ` `, loading the project explorer plus the file. File-manager targets keep their single-path behavior. * fix: address PR review feedback on file-open path resolution - Narrow the tilde guard so only genuine home-relative paths (~ or ~/…) are rejected; filenames like ~env.ts are now treated as workspace-relative. - Make the workspace-containment check case-insensitive for Windows drive paths so case-only differences still resolve a relative path. - Centralize the file-manager target id set in the protocol (isFileManagerTargetId) so the server and app no longer duplicate it. * feat(desktop): open workspace files from Electron * test(desktop): avoid mocked Electron editor IPC * fix(desktop): avoid Windows shell for editor executables * test(app): mock desktop editor bridge in e2e * fix(app): reuse resolved workspace open target path * fix(app): stabilize active file memo deps * fix(protocol): tag legacy editor RPC shims * fix(desktop): allow custom editor target ids * fix(desktop): open Windows editor paths with metacharacters Quote metacharacter values for .cmd editor wrappers so cmd.exe preserves them across wrapper execution instead of passing literal carets through. * fix(app): preserve reviewed file path handling --------- Co-authored-by: Mohamed Boudra --- .../app/src/workspace/file-open/index.test.ts | 31 +++++++++++++++++++ packages/app/src/workspace/file-open/index.ts | 25 ++++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/app/src/workspace/file-open/index.test.ts b/packages/app/src/workspace/file-open/index.test.ts index 6a31ac6ab..d7d2dd397 100644 --- a/packages/app/src/workspace/file-open/index.test.ts +++ b/packages/app/src/workspace/file-open/index.test.ts @@ -140,6 +140,37 @@ describe("resolveWorkspaceFilePaths", () => { expect( resolveWorkspaceFilePaths({ path: "~/notes.md", workspaceRoot: "/Users/me/repo" }), ).toBeNull(); + expect(resolveWorkspaceFilePaths({ path: "~", workspaceRoot: "/Users/me/repo" })).toBeNull(); + }); + + it("treats filenames that merely start with ~ as workspace-relative", () => { + expect(resolveWorkspaceFilePaths({ path: "~env.ts", workspaceRoot: "/Users/me/repo" })).toEqual( + { + absolutePath: "/Users/me/repo/~env.ts", + relativePath: "~env.ts", + }, + ); + }); + + it("matches Windows workspace paths case-insensitively", () => { + expect( + resolveWorkspaceFilePaths({ + path: "C:\\Users\\Me\\Repo\\src\\app.ts", + workspaceRoot: "c:\\users\\me\\repo", + }), + ).toEqual({ + absolutePath: "C:/Users/Me/Repo/src/app.ts", + relativePath: "src/app.ts", + }); + }); + + it("treats a case-only difference from the Windows root as the root itself", () => { + expect( + resolveWorkspaceFilePaths({ + path: "C:\\Users\\Me\\Repo", + workspaceRoot: "c:\\users\\me\\repo", + }), + ).toBeNull(); }); it("returns null when the workspace root is not absolute", () => { diff --git a/packages/app/src/workspace/file-open/index.ts b/packages/app/src/workspace/file-open/index.ts index 4a14a78a5..a823edd5d 100644 --- a/packages/app/src/workspace/file-open/index.ts +++ b/packages/app/src/workspace/file-open/index.ts @@ -113,6 +113,22 @@ function normalizeRelativePath(value: string): string | null { return normalized || null; } +function isWindowsPath(value: string): boolean { + return /^[A-Za-z]:\//.test(value); +} + +function pathsEqual(left: string, right: string): boolean { + return isWindowsPath(left) || isWindowsPath(right) + ? left.toLowerCase() === right.toLowerCase() + : left === right; +} + +function startsWithPath(value: string, prefix: string): boolean { + return isWindowsPath(value) || isWindowsPath(prefix) + ? value.toLowerCase().startsWith(prefix.toLowerCase()) + : value.startsWith(prefix); +} + export interface ResolvedWorkspaceFilePaths { /** Absolute path on the host, suitable for opening in an editor / file manager. */ absolutePath: string; @@ -140,16 +156,17 @@ export function resolveWorkspaceFilePaths(input: { if (!normalizedFile) { return null; } - if (normalizedFile === workspaceRoot) { + if (pathsEqual(normalizedFile, workspaceRoot)) { return null; } - const relativePath = normalizedFile.startsWith(`${workspaceRoot}/`) - ? normalizedFile.slice(workspaceRoot.length + 1) + const prefix = `${workspaceRoot}/`; + const relativePath = startsWithPath(normalizedFile, prefix) + ? normalizedFile.slice(prefix.length) : null; return { absolutePath: normalizedFile, relativePath }; } - if (filePath.startsWith("~")) { + if (filePath === "~" || filePath.startsWith("~/")) { return null; } From 91ef9301c2ce0afb41512e5e3644b1bc3a547790 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Jun 2026 19:49:36 +0800 Subject: [PATCH 4/4] Show question prompts one at a time * Show question prompts one at a time * Make question prompt submit action literal --- packages/app/e2e/helpers/questions.ts | 61 ++++ .../e2e/question-prompt-pagination.spec.ts | 73 +++++ .../app/src/components/question-form-card.tsx | 300 +++++++++++++----- .../agent/providers/mock-load-test-agent.ts | 79 ++++- 4 files changed, 435 insertions(+), 78 deletions(-) create mode 100644 packages/app/e2e/helpers/questions.ts create mode 100644 packages/app/e2e/question-prompt-pagination.spec.ts diff --git a/packages/app/e2e/helpers/questions.ts b/packages/app/e2e/helpers/questions.ts new file mode 100644 index 000000000..93f0b4442 --- /dev/null +++ b/packages/app/e2e/helpers/questions.ts @@ -0,0 +1,61 @@ +import { expect, type Page } from "@playwright/test"; + +export async function waitForQuestionPrompt(page: Page, timeout = 30_000): Promise { + await expect(page.getByTestId("question-form-card").first()).toBeVisible({ timeout }); +} + +export async function expectCurrentQuestion( + page: Page, + input: { index: number; total: number; question: string }, +): Promise { + const card = page.getByTestId("question-form-card").first(); + await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question); + await expect( + card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }), + ).toHaveAttribute("aria-selected", "true"); +} + +export async function expectQuestionHidden(page: Page, question: string): Promise { + await expect(page.getByText(question, { exact: true })).toHaveCount(0); +} + +export async function chooseQuestionOption(page: Page, option: string): Promise { + await page + .getByTestId("question-form-card") + .first() + .getByRole("button", { name: option }) + .click(); +} + +export async function expectQuestionOptionSelected(page: Page, option: string): Promise { + await expect( + page.getByTestId("question-form-card").first().getByRole("button", { name: option }), + ).toHaveAttribute("aria-selected", "true"); +} + +export async function openQuestion( + page: Page, + input: { index: number; total: number }, +): Promise { + await page + .getByTestId("question-form-card") + .first() + .getByRole("button", { name: `Question ${input.index} of ${input.total}` }) + .click(); +} + +export async function fillQuestionAnswer( + page: Page, + input: { question: string; answer: string }, +): Promise { + await page + .getByTestId("question-form-card") + .first() + .getByRole("textbox", { name: input.question }) + .fill(input.answer); +} + +export async function submitQuestionAnswers(page: Page): Promise { + await page.getByTestId("question-form-primary-action").click(); + await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 }); +} diff --git a/packages/app/e2e/question-prompt-pagination.spec.ts b/packages/app/e2e/question-prompt-pagination.spec.ts new file mode 100644 index 000000000..60f8520f0 --- /dev/null +++ b/packages/app/e2e/question-prompt-pagination.spec.ts @@ -0,0 +1,73 @@ +import { test } from "./fixtures"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { + chooseQuestionOption, + expectCurrentQuestion, + expectQuestionHidden, + expectQuestionOptionSelected, + fillQuestionAnswer, + openQuestion, + submitQuestionAnswers, + waitForQuestionPrompt, +} from "./helpers/questions"; + +const TOTAL_QUESTIONS = 3; +const SURFACE_QUESTION = "Which surface should this apply to?"; +const ROLLOUT_QUESTION = "Which rollout should we use?"; +const SUCCESS_QUESTION = "What success criteria should we use?"; + +test.describe("Question prompt pagination", () => { + test("shows one question at a time with numbered navigation", async ({ page }) => { + test.setTimeout(180_000); + + const session = await seedMockAgentWorkspace({ + repoPrefix: "question-pagination-", + title: "Question pagination e2e", + initialPrompt: "Emit synthetic questions.", + }); + + try { + await openAgentRoute(page, session); + await waitForQuestionPrompt(page, 120_000); + + await expectCurrentQuestion(page, { + index: 1, + total: TOTAL_QUESTIONS, + question: SURFACE_QUESTION, + }); + await expectQuestionHidden(page, ROLLOUT_QUESTION); + await expectQuestionHidden(page, SUCCESS_QUESTION); + + await chooseQuestionOption(page, "App"); + await expectCurrentQuestion(page, { + index: 2, + total: TOTAL_QUESTIONS, + question: ROLLOUT_QUESTION, + }); + + await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS }); + await expectCurrentQuestion(page, { + index: 1, + total: TOTAL_QUESTIONS, + question: SURFACE_QUESTION, + }); + await expectQuestionOptionSelected(page, "App"); + + await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS }); + await chooseQuestionOption(page, "Behind feature flag"); + await expectCurrentQuestion(page, { + index: 3, + total: TOTAL_QUESTIONS, + question: SUCCESS_QUESTION, + }); + + await fillQuestionAnswer(page, { + question: SUCCESS_QUESTION, + answer: "Only one prompt is visible at a time.", + }); + await submitQuestionAnswers(page); + } finally { + await session.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 527ee4b2e..29cfe681f 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -9,7 +9,7 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { Check, CircleHelp, X } from "lucide-react-native"; +import { Check, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { isWeb } from "@/constants/platform"; @@ -82,9 +82,18 @@ function QuestionOptionRow({ () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], ); + const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); return ( - + {option.label} @@ -102,8 +111,73 @@ function QuestionOptionRow({ ); } +interface QuestionNavButtonProps { + index: number; + total: number; + isActive: boolean; + isResponding: boolean; + onSelect: (index: number) => void; +} + +function QuestionNavButton({ + index, + total, + isActive, + isResponding, + onSelect, +}: QuestionNavButtonProps) { + const { theme } = useUnistyles(); + const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]); + const handlePress = useCallback(() => { + onSelect(index); + }, [index, onSelect]); + const pressableStyle = useCallback( + ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => { + return [ + styles.questionNavButton, + { + backgroundColor: + isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1, + borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border, + }, + pressed && styles.optionItemPressed, + ]; + }, + [ + isActive, + theme.colors.border, + theme.colors.foregroundMuted, + theme.colors.surface1, + theme.colors.surface2, + ], + ); + const textStyle = useMemo( + () => [ + styles.questionNavText, + { color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted }, + ], + [isActive, theme.colors.foreground, theme.colors.foregroundMuted], + ); + + return ( + + {index + 1} + + ); +} + interface QuestionOtherInputProps { qIndex: number; + accessibilityLabel: string; value: string; placeholder: string; isResponding: boolean; @@ -113,6 +187,7 @@ interface QuestionOtherInputProps { function QuestionOtherInput({ qIndex, + accessibilityLabel, value, placeholder, isResponding, @@ -149,6 +224,7 @@ function QuestionOtherInput({ parseQuestionFormQuestions(permission.request.input), + [permission.request.input], + ); const [selections, setSelections] = useState>>({}); const [otherTexts, setOtherTexts] = useState>({}); const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); + const [activeQuestionIndex, setActiveQuestionIndex] = useState(0); - const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => { - setSelections((prev) => { - const current = prev[qIndex] ?? new Set(); + const toggleOption = useCallback( + (qIndex: number, optIndex: number, multiSelect: boolean) => { + const current = selections[qIndex] ?? new Set(); const next = new Set(current); if (multiSelect) { if (next.has(optIndex)) { @@ -179,23 +259,27 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi } else { next.add(optIndex); } + } else if (next.has(optIndex)) { + next.clear(); } else { - if (next.has(optIndex)) { - next.clear(); - } else { - next.clear(); - next.add(optIndex); - } + next.clear(); + next.add(optIndex); } - return { ...prev, [qIndex]: next }; - }); - setOtherTexts((prev) => { - if (!prev[qIndex]) return prev; - const next = { ...prev }; - delete next[qIndex]; - return next; - }); - }, []); + + setSelections((prev) => ({ ...prev, [qIndex]: next })); + setOtherTexts((prev) => { + if (!prev[qIndex]) return prev; + const nextTexts = { ...prev }; + delete nextTexts[qIndex]; + return nextTexts; + }); + + if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) { + setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1)); + } + }, + [activeQuestionIndex, questions, selections], + ); const setOtherText = useCallback((qIndex: number, text: string) => { setOtherTexts((prev) => ({ ...prev, [qIndex]: text })); @@ -208,6 +292,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi }, []); const allAnswered = areQuestionsAnswered(questions, selections, otherTexts); + const resolvedActiveQuestionIndex = questions + ? Math.min(activeQuestionIndex, questions.length - 1) + : 0; + const activeQuestion = questions?.[resolvedActiveQuestionIndex]; const handleSubmit = useCallback(() => { if (!questions || !allAnswered || isResponding) return; @@ -248,6 +336,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi }); }, [questions, onRespond, otherTexts, permission.request.input, selections]); + const handleSelectQuestion = useCallback((index: number) => { + setActiveQuestionIndex(index); + }, []); + const dismissButtonStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.actionButton, @@ -262,22 +354,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi const submitDisabled = !allAnswered || isResponding; const submitButtonStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ + ({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.actionButton, { - backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1, - borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent, + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, opacity: submitDisabled ? 0.5 : 1, }, pressed && !submitDisabled ? styles.optionItemPressed : null, ], - [ - submitDisabled, - theme.colors.surface2, - theme.colors.surface1, - theme.colors.border, - theme.colors.borderAccent, - ], + [submitDisabled, theme.colors.accent], ); const containerStyle = useMemo( @@ -294,6 +380,10 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi () => [styles.questionText, { color: theme.colors.foreground }], [theme.colors.foreground], ); + const questionNavStyle = useMemo( + () => [styles.questionNav, isMobile && styles.questionNavMobile], + [isMobile], + ); const actionsContainerStyle = useMemo( () => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop], [isMobile], @@ -302,9 +392,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi () => [styles.actionText, { color: theme.colors.foregroundMuted }], [theme.colors.foregroundMuted], ); - const submitActionTextColor = allAnswered - ? theme.colors.foreground - : theme.colors.foregroundMuted; + const submitActionTextColor = theme.colors.accentForeground; const submitActionTextStyle = useMemo( () => [styles.actionText, { color: submitActionTextColor }], [submitActionTextColor], @@ -315,52 +403,76 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi } const dismissLabel = resolveDismissLabel(questions); + const selected = selections[resolvedActiveQuestionIndex] ?? new Set(); + const otherText = otherTexts[resolvedActiveQuestionIndex] ?? ""; + const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false; return ( - - {questions.map((q, qIndex) => { - const selected = selections[qIndex] ?? new Set(); - const otherText = otherTexts[qIndex] ?? ""; - const showTextInput = questionShowsTextInput(q); - - return ( - - - {q.question} - - - {q.options.length > 0 ? ( - - {q.options.map((opt, optIndex) => ( - - ))} - - ) : null} - {showTextInput ? ( - + + + + {activeQuestion?.question} + + + + {questions.map((question, qIndex) => { + const isActive = qIndex === resolvedActiveQuestionIndex; + return ( + - ) : null} - - ); - })} + ); + })} + + + + {activeQuestion ? ( + + {activeQuestion.options.length > 0 ? ( + + {activeQuestion.options.map((opt, optIndex) => ( + + ))} + + ) : null} + {showTextInput ? ( + + ) : null} + + ) : null} - + {respondingAction === "dismiss" ? ( ) : ( @@ -371,9 +483,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi )} - + {respondingAction === "submit" ? ( - + ) : ( @@ -396,21 +515,50 @@ const styles = StyleSheet.create((theme) => ({ questionBlock: { gap: theme.spacing[2], }, + questionTopRow: { + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: theme.spacing[3], + }, questionHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[1], + flex: 1, }, questionText: { flex: 1, fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, lineHeight: 22, }, optionsWrap: { gap: theme.spacing[1], }, + questionNav: { + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing[1], + }, + questionNavMobile: { + paddingRight: theme.spacing[1], + }, + questionNavButton: { + minWidth: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + borderRadius: 999, + borderWidth: theme.borderWidth[1], + }, + questionNavText: { + fontSize: theme.fontSize.xs, + fontWeight: "700", + }, optionItem: { flexDirection: "row", alignItems: "center", diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 64d34bc34..6f7140930 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -128,6 +128,10 @@ function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean { return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt)); } +function shouldEmitQuestionPrompt(prompt: AgentPromptInput): boolean { + return /emit\s+(?:a\s+)?synthetic\s+questions?/i.test(promptToText(prompt)); +} + function resolveModelProfile(modelId: string | null | undefined): { modelId: string; durationMs: number; @@ -524,6 +528,8 @@ export class MockLoadTestAgentSession implements AgentSession { const stress = parseAgentStreamStressPrompt(prompt); if (shouldEmitPlanApprovalPrompt(prompt)) { this.schedulePlanApprovalTurn(turn); + } else if (shouldEmitQuestionPrompt(prompt)) { + this.scheduleQuestionPromptTurn(turn); } else if (largePayload) { this.scheduleLargePayloadTurn(turn, largePayload); } else if (stress) { @@ -576,9 +582,11 @@ export class MockLoadTestAgentSession implements AgentSession { requestId: string, response: AgentPermissionResponse, ): Promise { - if (!this.pendingPermissions.delete(requestId)) { + const request = this.pendingPermissions.get(requestId); + if (!request) { return undefined; } + this.pendingPermissions.delete(requestId); const turn = this.activeTurn; this.emit({ @@ -590,7 +598,12 @@ export class MockLoadTestAgentSession implements AgentSession { }); if (turn) { - this.finishTurnWithText(turn, "Synthetic plan approval resolved"); + this.finishTurnWithText( + turn, + request.kind === "question" + ? "Synthetic questions resolved" + : "Synthetic plan approval resolved", + ); } return undefined; } @@ -689,6 +702,13 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleQuestionPromptTurn(turn: ActiveTurn): void { + turn.timer = setTimeout(() => { + this.emitQuestionPromptTurn(turn); + }, 0); + turn.timer.unref?.(); + } + private emitPlanApprovalTurn(turn: ActiveTurn): void { if (this.activeTurn !== turn) { return; @@ -741,6 +761,61 @@ export class MockLoadTestAgentSession implements AgentSession { }); } + private emitQuestionPromptTurn(turn: ActiveTurn): void { + if (this.activeTurn !== turn) { + return; + } + + this.clearTurnTimer(turn); + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + + const request: AgentPermissionRequest = { + id: `mock-questions-${turn.turnId}`, + provider: this.provider, + name: "MockQuestions", + kind: "question", + title: "Questions", + input: { + questions: [ + { + question: "Which surface should this apply to?", + header: "surface", + options: [{ label: "App" }, { label: "Desktop" }], + multiSelect: false, + }, + { + question: "Which rollout should we use?", + header: "rollout", + options: [{ label: "Immediately" }, { label: "Behind feature flag" }], + multiSelect: false, + }, + { + question: "What success criteria should we use?", + header: "success", + options: [], + multiSelect: false, + placeholder: "Describe success...", + }, + ], + }, + metadata: { + source: "mock_questions", + }, + }; + + this.pendingPermissions.set(request.id, request); + this.emit({ + type: "permission_requested", + provider: this.provider, + request, + turnId: turn.turnId, + }); + } + private emitStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void { if (this.activeTurn !== turn) { return;