From b897c59e9c72cc3b0721287e9860fc3200d41d38 Mon Sep 17 00:00:00 2001 From: Slava Goltser Date: Wed, 29 Jul 2026 10:10:18 -0400 Subject: [PATCH 01/46] fix: preserve context window data when turn_completed lacks usage (#2494) * fix: preserve context window data when turn_completed lacks usage Merge incoming usage into agent.lastUsage instead of replacing it wholesale. Some providers (Claude autonomous turns, OpenCode) emit turn_completed without a usage payload, which previously wiped the contextWindowMaxTokens/contextWindowUsedTokens accumulated during streaming usage_updated events. The result was the context window meter disappearing from the composer once the agent finished work. * test(server): preserve usage after autonomous completion --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-manager.test.ts | 17 ++++++++++++++++- .../server/src/server/agent/agent-manager.ts | 7 ++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 1ff1b19337..d55f1c5986 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -4757,7 +4757,7 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the unsubscribe(); }); -test("applies live autonomous events while no foreground run is active", async () => { +test("applies live autonomous events and preserves usage omitted from completion", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-events-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -4816,6 +4816,16 @@ test("applies live autonomous events while no foreground run is active", async ( provider: "codex", turnId: autonomousTurnId, }); + capturedSession!.pushEvent({ + type: "usage_updated", + provider: "codex", + usage: { + inputTokens: 10, + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 175, + }, + turnId: autonomousTurnId, + }); capturedSession!.pushEvent({ type: "timeline", provider: "codex", @@ -4831,6 +4841,11 @@ test("applies live autonomous events while no foreground run is active", async ( const updated = manager.getAgent(snapshot.id); expect(updated?.lifecycle).toBe("idle"); + expect(updated?.lastUsage).toEqual({ + inputTokens: 10, + contextWindowMaxTokens: 200_000, + contextWindowUsedTokens: 175, + }); expect(manager.getTimeline(snapshot.id)).toContainEqual({ type: "assistant_message", text: "AUTONOMOUS_PUMP_MESSAGE", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index e6c4e68a88..c18e8c902c 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -3552,7 +3552,12 @@ export class AgentManager { }, "agent.manager.turn.completed", ); - agent.lastUsage = event.usage; + if (event.usage) { + agent.lastUsage = { ...agent.lastUsage, ...event.usage }; + } + // If no usage on turn_completed, keep lastUsage as-is so context window + // data accumulated during streaming isn't lost when the provider omits + // it from the completion event. agent.lastError = undefined; if (!isForegroundEvent && agent.lifecycle !== "idle" && !agent.pendingReplacement) { (agent as ActiveManagedAgent).lifecycle = "idle"; From 68a215d40c8102342df2d7720c364afd110a86ab Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 16:10:51 +0200 Subject: [PATCH 02/46] Prevent restored file trees from crashing desktop startup (#2595) * fix(explorer): prevent persisted trees from crashing startup Persisted expansion restored every directory and recursively flattened the resulting tree during render. Limit automatic restoration to five rendered directory levels and flatten iteratively so deep or broad workspaces cannot exhaust the JavaScript stack. * fix(explorer): preserve expansion changes during restore Reconcile completed restoration against the current expansion set atomically so folder interactions made while directory requests are in flight are not overwritten. * fix(explorer): show hidden files before restoration * fix(explorer): apply directory toggles atomically --- .../app/src/components/file-explorer-pane.tsx | 259 ++++++++---------- packages/app/src/file-explorer/tree.test.ts | 197 +++++++++++++ packages/app/src/file-explorer/tree.ts | 203 ++++++++++++++ .../src/hooks/use-file-explorer-actions.ts | 18 +- packages/app/src/stores/panel-store/index.ts | 17 +- packages/app/src/stores/session-store.ts | 2 +- 6 files changed, 536 insertions(+), 160 deletions(-) create mode 100644 packages/app/src/file-explorer/tree.test.ts create mode 100644 packages/app/src/file-explorer/tree.ts diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 540a02bd9a..e2cc7759e4 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -24,16 +24,28 @@ import { } from "@/components/tree-primitives"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { Theme } from "@/styles/theme"; -import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; +import type { + AgentFileExplorerState, + ExplorerDirectory, + ExplorerEntry, +} from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { FileActionsMenu } from "@/components/file-actions-menu"; import { useFileDownload } from "@/hooks/use-file-download"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; -import { usePanelStore, type SortOption } from "@/stores/panel-store"; +import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; -import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility"; +import { isHiddenExplorerPath } from "@/file-explorer/visibility"; +import { + flattenExplorerTree, + reconcileRestoredExpandedPaths, + restoreExpandedDirectories, + setExpandedDirectoryPath, + showHiddenFilesAndRestoreExpandedDirectories, + type ExplorerTreeRow, +} from "@/file-explorer/tree"; import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source"; const SORT_OPTIONS: { value: SortOption }[] = [ @@ -83,7 +95,7 @@ function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { ho return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered]; } -function treeRowKeyExtractor(row: TreeRow) { +function treeRowKeyExtractor(row: ExplorerTreeRow) { return row.entry.path; } @@ -199,11 +211,6 @@ interface FileExplorerPaneProps { onAddToChat?: (path: string) => void; } -interface TreeRow { - entry: ExplorerEntry; - depth: number; -} - export function FileExplorerPane({ serverId, workspaceId, @@ -263,7 +270,7 @@ export function FileExplorerPane({ [isExplorerLoading, pendingRequest], ); - const treeListRef = useRef>(null); + const treeListRef = useRef>(null); const hasInitializedRef = useRef(false); @@ -276,9 +283,19 @@ export function FileExplorerPane({ hasWorkspaceScope, hasInitializedRef, workspaceStateKey, + persistedExpandedPaths: expandedPaths, + showHiddenFiles, requestDirectoryListing, + setExpandedPathsForWorkspace, }); - }, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]); + }, [ + expandedPaths, + hasWorkspaceScope, + requestDirectoryListing, + setExpandedPathsForWorkspace, + showHiddenFiles, + workspaceStateKey, + ]); const handleToggleDirectory = useCallback( (entry: ExplorerEntry) => @@ -351,11 +368,42 @@ export function FileExplorerPane({ const handleToggleHiddenFiles = useCallback(() => { const willShow = !usePanelStore.getState().explorerShowHiddenFiles; - toggleExplorerShowHiddenFiles(); - if (willShow) { - requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing }); + if (!willShow) { + toggleExplorerShowHiddenFiles(); + return; } - }, [requestDirectoryListing, toggleExplorerShowHiddenFiles, workspaceStateKey]); + const rootDirectory = directories.get("."); + if (!rootDirectory || !workspaceStateKey) { + toggleExplorerShowHiddenFiles(); + return; + } + void showHiddenFilesAndRestoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: expandedPaths, + showHiddenFiles: toggleExplorerShowHiddenFiles, + requestDirectoryListing: (path) => + requestDirectoryListing(path, { + recordHistory: false, + setCurrentPath: false, + }), + }).then((restoredPaths) => { + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + reconcileRestoredExpandedPaths({ + persistedExpandedPaths: expandedPaths, + currentExpandedPaths: new Set(currentPaths), + restoredExpandedPaths: restoredPaths, + }), + ); + return null; + }); + }, [ + directories, + expandedPaths, + requestDirectoryListing, + setExpandedPathsForWorkspace, + toggleExplorerShowHiddenFiles, + workspaceStateKey, + ]); const refreshExplorer = useCallback( () => @@ -387,7 +435,7 @@ export function FileExplorerPane({ const currentSortLabel = resolveCurrentSortLabel(sortOption, sortLabels); const treeRows = useMemo( - () => resolveTreeRows({ directories, expandedPaths, sortOption, showHiddenFiles }), + () => flattenExplorerTree({ directories, expandedPaths, sortOption, showHiddenFiles }), [directories, expandedPaths, showHiddenFiles, sortOption], ); @@ -400,7 +448,7 @@ export function FileExplorerPane({ const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]); const renderTreeRow = useCallback( - (info: ListRenderItemInfo) => ( + (info: ListRenderItemInfo) => ( | null>; - renderTreeRow: (info: ListRenderItemInfo) => ReactElement; + treeListRef: RefObject | null>; + renderTreeRow: (info: ListRenderItemInfo) => ReactElement; handleSortCycle: () => void; handleToggleHiddenFiles: () => void; handleRefresh: () => void; @@ -637,71 +685,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { ); } -function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] { - const sorted = [...entries]; - sorted.sort((a, b) => { - if (a.kind !== b.kind) { - return a.kind === "directory" ? -1 : 1; - } - switch (sortOption) { - case "name": - return a.name.localeCompare(b.name); - case "modified": - return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); - case "size": - return b.size - a.size; - default: - return 0; - } - }); - return sorted; -} - -function buildTreeRows({ - directories, - expandedPaths, - sortOption, - showHiddenFiles, - path, - depth, -}: { - directories: Map; - expandedPaths: Set; - sortOption: SortOption; - showHiddenFiles: boolean; - path: string; - depth: number; -}): TreeRow[] { - const directory = directories.get(path); - if (!directory) { - return []; - } - - const rows: TreeRow[] = []; - const entries = sortEntries( - filterVisibleExplorerEntries(directory.entries, showHiddenFiles), - sortOption, - ); - - for (const entry of entries) { - rows.push({ entry, depth }); - if (entry.kind === "directory" && expandedPaths.has(entry.path)) { - rows.push( - ...buildTreeRows({ - directories, - expandedPaths, - sortOption, - showHiddenFiles, - path: entry.path, - depth: depth + 1, - }), - ); - } - } - - return rows; -} - function deriveExplorerFields(state: AgentFileExplorerState | undefined) { return { directories: @@ -751,30 +734,6 @@ function resolveCurrentSortLabel( return labels[sortOption] ?? labels.name; } -function resolveTreeRows({ - directories, - expandedPaths, - sortOption, - showHiddenFiles, -}: { - directories: Map; - expandedPaths: Set; - sortOption: SortOption; - showHiddenFiles: boolean; -}): TreeRow[] { - if (!directories.get(".")) { - return []; - } - return buildTreeRows({ - directories, - expandedPaths, - sortOption, - showHiddenFiles, - path: ".", - depth: 0, - }); -} - function toggleDirectory({ entry, workspaceStateKey, @@ -786,26 +745,25 @@ function toggleDirectory({ entry: ExplorerEntry; workspaceStateKey: string | null; expandedPaths: Set; - directories: Map; + directories: Map; requestDirectoryListing: ( path: string, opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; - setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: string[]) => void; + ) => Promise; + setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void; }): void { if (!workspaceStateKey) { return; } const isExpanded = expandedPaths.has(entry.path); - if (isExpanded) { - setExpandedPathsForWorkspace( - workspaceStateKey, - Array.from(expandedPaths).filter((path) => path !== entry.path), - ); - return; - } - setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), entry.path]); - if (!directories.has(entry.path)) { + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + setExpandedDirectoryPath({ + currentExpandedPaths: currentPaths, + directoryPath: entry.path, + expanded: !isExpanded, + }), + ); + if (!isExpanded && !directories.has(entry.path)) { void requestDirectoryListing(entry.path, { recordHistory: false, setCurrentPath: false, @@ -827,7 +785,7 @@ function TreeRowDispatcher({ }: { serverId: string; workspaceId?: string | null; - info: ListRenderItemInfo; + info: ListRenderItemInfo; expandedPaths: Set; selectedEntryPath: string | null; isDirectoryLoading: (path: string) => boolean; @@ -865,54 +823,59 @@ async function initializeExplorer({ hasWorkspaceScope, hasInitializedRef, workspaceStateKey, + persistedExpandedPaths, + showHiddenFiles, requestDirectoryListing, + setExpandedPathsForWorkspace, }: { hasWorkspaceScope: boolean; hasInitializedRef: RefObject; workspaceStateKey: string | null; + persistedExpandedPaths: ReadonlySet; + showHiddenFiles: boolean; requestDirectoryListing: ( path: string, opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; + ) => Promise; + setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void; }): Promise { if (!hasWorkspaceScope || hasInitializedRef.current) { return; } hasInitializedRef.current = true; - const succeeded = await requestDirectoryListing(".", { + const rootDirectory = await requestDirectoryListing(".", { recordHistory: false, setCurrentPath: false, }); - if (!succeeded) { + if (!rootDirectory) { hasInitializedRef.current = false; return; } - requestPersistedExpandedPaths({ workspaceStateKey, requestDirectoryListing }); -} - -function requestPersistedExpandedPaths({ - workspaceStateKey, - requestDirectoryListing, -}: { - workspaceStateKey: string | null; - requestDirectoryListing: ( - path: string, - opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; -}): void { - const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles; - const persistedPaths = usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""]; - if (!persistedPaths) { + if (!workspaceStateKey) { return; } - for (const path of persistedPaths) { - if (path !== "." && (showHiddenFiles || !isHiddenExplorerPath(path))) { - void requestDirectoryListing(path, { + + const restoredPaths = await restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles, + requestDirectoryListing: (path) => + requestDirectoryListing(path, { recordHistory: false, setCurrentPath: false, - }); - } - } + }), + }); + const hiddenPersistedPaths = showHiddenFiles + ? [] + : Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath); + const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths]; + setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) => + reconcileRestoredExpandedPaths({ + persistedExpandedPaths, + currentExpandedPaths: new Set(currentPaths), + restoredExpandedPaths: restoredPathsWithHidden, + }), + ); } async function refreshExplorerDirectories({ @@ -925,7 +888,7 @@ async function refreshExplorerDirectories({ requestDirectoryListing: ( path: string, opts?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ) => Promise; + ) => Promise; }): Promise { if (!hasWorkspaceScope) { return null; diff --git a/packages/app/src/file-explorer/tree.test.ts b/packages/app/src/file-explorer/tree.test.ts new file mode 100644 index 0000000000..e3df026c0d --- /dev/null +++ b/packages/app/src/file-explorer/tree.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import type { ExplorerEntry } from "@/stores/session-store"; +import { + MAX_AUTO_EXPANDED_DIRECTORY_DEPTH, + flattenExplorerTree, + reconcileRestoredExpandedPaths, + restoreExpandedDirectories, + setExpandedDirectoryPath, + showHiddenFilesAndRestoreExpandedDirectories, +} from "./tree"; + +function makeDirectoryEntry(name: string, path: string): ExplorerEntry { + return { + name, + path, + kind: "directory", + size: 0, + modifiedAt: "2026-01-01T00:00:00.000Z", + }; +} + +describe("file explorer tree", () => { + it("flattens a deeply expanded tree without consuming the call stack", () => { + const depth = 10_000; + const directories = new Map(); + const expandedPaths = new Set(["."]); + let parentPath = "."; + + for (let index = 1; index <= depth; index += 1) { + const childPath = `directory-${index}`; + directories.set(parentPath, { + path: parentPath, + entries: [makeDirectoryEntry(childPath, childPath)], + }); + expandedPaths.add(childPath); + parentPath = childPath; + } + directories.set(parentPath, { path: parentPath, entries: [] }); + + const rows = flattenExplorerTree({ + directories, + expandedPaths, + sortOption: "name", + showHiddenFiles: true, + }); + + expect(rows).toHaveLength(depth); + expect(rows[0]).toEqual({ + entry: makeDirectoryEntry("directory-1", "directory-1"), + depth: 0, + }); + expect(rows.at(-1)).toEqual({ + entry: makeDirectoryEntry(`directory-${depth}`, `directory-${depth}`), + depth: depth - 1, + }); + }); + + it("flattens a large expanded directory without spreading its rows into the parent", () => { + const fileCount = 150_000; + const files = Array.from( + { length: fileCount }, + (_, index): ExplorerEntry => ({ + name: `file-${index.toString().padStart(6, "0")}`, + path: `generated/file-${index}`, + kind: "file", + size: index, + modifiedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const child = makeDirectoryEntry("generated", "generated"); + const directories = new Map([ + [".", { path: ".", entries: [child] }], + ["generated", { path: "generated", entries: files }], + ]); + + const rows = flattenExplorerTree({ + directories, + expandedPaths: new Set([".", "generated"]), + sortOption: "name", + showHiddenFiles: true, + }); + + expect(rows).toHaveLength(fileCount + 1); + expect(rows[0]).toEqual({ entry: child, depth: 0 }); + expect(rows.at(-1)).toEqual({ entry: files[fileCount - 1], depth: 1 }); + }); + + it("restores five rendered directory levels rather than counting path segments", async () => { + const paths = [ + "generated/cache/level-1", + "generated/cache/level-1/level-2", + "generated/cache/level-1/level-2/level-3", + "generated/cache/level-1/level-2/level-3/level-4", + "generated/cache/level-1/level-2/level-3/level-4/level-5", + "generated/cache/level-1/level-2/level-3/level-4/level-5/level-6", + ]; + const directories = new Map(); + const rootDirectory = { + path: ".", + entries: [makeDirectoryEntry("level-1", paths[0])], + }; + directories.set(".", rootDirectory); + for (let index = 0; index < paths.length - 1; index += 1) { + directories.set(paths[index], { + path: paths[index], + entries: [makeDirectoryEntry(`level-${index + 2}`, paths[index + 1])], + }); + } + + const requestedPaths: string[] = []; + const expandedPaths = await restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: new Set(paths), + showHiddenFiles: true, + requestDirectoryListing: async (path) => { + requestedPaths.push(path); + return directories.get(path) ?? null; + }, + }); + + expect(MAX_AUTO_EXPANDED_DIRECTORY_DEPTH).toBe(5); + expect(requestedPaths).toEqual(paths.slice(0, 5)); + expect(expandedPaths).toEqual([".", ...paths.slice(0, 5)]); + }); + + it("does not restore persisted descendants beneath a collapsed rendered directory", async () => { + const rootDirectory = { + path: ".", + entries: [makeDirectoryEntry("parent", "parent")], + }; + const requestedPaths: string[] = []; + + const expandedPaths = await restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: new Set(["parent/child", "parent/child/grandchild"]), + showHiddenFiles: true, + requestDirectoryListing: async (path) => { + requestedPaths.push(path); + return null; + }, + }); + + expect(requestedPaths).toEqual([]); + expect(expandedPaths).toEqual(["."]); + }); + + it("preserves expansion changes made while persisted directories are restoring", () => { + const paths = reconcileRestoredExpandedPaths({ + persistedExpandedPaths: new Set([".", "parent", "parent/child"]), + currentExpandedPaths: new Set([".", "parent/child", "manual"]), + restoredExpandedPaths: [".", "parent"], + }); + + expect(paths).toEqual([".", "manual"]); + }); + + it("applies a directory click to the latest restored expansion paths", () => { + const expanded = setExpandedDirectoryPath({ + currentExpandedPaths: [".", "restored"], + directoryPath: "manual", + expanded: true, + }); + const collapsed = setExpandedDirectoryPath({ + currentExpandedPaths: expanded, + directoryPath: "manual", + expanded: false, + }); + + expect(expanded).toEqual([".", "restored", "manual"]); + expect(collapsed).toEqual([".", "restored"]); + }); + + it("shows hidden files before waiting for expanded directories to restore", async () => { + const rootDirectory = { + path: ".", + entries: [makeDirectoryEntry(".hidden", ".hidden")], + }; + let resolveDirectory!: (directory: { path: string; entries: ExplorerEntry[] }) => void; + const directoryListing = new Promise<{ path: string; entries: ExplorerEntry[] }>((resolve) => { + resolveDirectory = resolve; + }); + let hiddenFilesAreShown = false; + + const restoration = showHiddenFilesAndRestoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths: new Set([".hidden"]), + showHiddenFiles: () => { + hiddenFilesAreShown = true; + }, + requestDirectoryListing: () => directoryListing, + }); + + expect(hiddenFilesAreShown).toBe(true); + resolveDirectory({ path: ".hidden", entries: [] }); + await expect(restoration).resolves.toEqual([".", ".hidden"]); + }); +}); diff --git a/packages/app/src/file-explorer/tree.ts b/packages/app/src/file-explorer/tree.ts new file mode 100644 index 0000000000..472c3d7075 --- /dev/null +++ b/packages/app/src/file-explorer/tree.ts @@ -0,0 +1,203 @@ +import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store"; +import type { SortOption } from "@/stores/panel-store/state"; +import { filterVisibleExplorerEntries } from "./visibility"; + +export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5; + +export interface ExplorerTreeRow { + entry: ExplorerEntry; + depth: number; +} + +interface FlattenExplorerTreeInput { + directories: ReadonlyMap; + expandedPaths: ReadonlySet; + sortOption: SortOption; + showHiddenFiles: boolean; +} + +interface RestoreExpandedDirectoriesInput { + rootDirectory: ExplorerDirectory; + persistedExpandedPaths: ReadonlySet; + showHiddenFiles: boolean; + requestDirectoryListing: (path: string) => Promise; +} + +interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit< + RestoreExpandedDirectoriesInput, + "showHiddenFiles" +> { + showHiddenFiles: () => void; +} + +interface ReconcileRestoredExpandedPathsInput { + persistedExpandedPaths: ReadonlySet; + currentExpandedPaths: ReadonlySet; + restoredExpandedPaths: string[]; +} + +interface SetExpandedDirectoryPathInput { + currentExpandedPaths: readonly string[]; + directoryPath: string; + expanded: boolean; +} + +export function flattenExplorerTree({ + directories, + expandedPaths, + sortOption, + showHiddenFiles, +}: FlattenExplorerTreeInput): ExplorerTreeRow[] { + const root = directories.get("."); + if (!root) { + return []; + } + + const rows: ExplorerTreeRow[] = []; + const pending = rowsForDirectory(root, 0, sortOption, showHiddenFiles).toReversed(); + + while (pending.length > 0) { + const row = pending.pop(); + if (!row) { + break; + } + rows.push(row); + + const entry = row.entry; + if (entry.kind !== "directory" || !expandedPaths.has(entry.path)) { + continue; + } + const childDirectory = directories.get(entry.path); + if (!childDirectory) { + continue; + } + const childRows = rowsForDirectory(childDirectory, row.depth + 1, sortOption, showHiddenFiles); + for (let index = childRows.length - 1; index >= 0; index -= 1) { + pending.push(childRows[index]); + } + } + + return rows; +} + +export async function restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles, + requestDirectoryListing, +}: RestoreExpandedDirectoriesInput): Promise { + const restoredPaths = ["."]; + const restoredPathSet = new Set(restoredPaths); + let parentDirectories = [rootDirectory]; + + for (let depth = 1; depth <= MAX_AUTO_EXPANDED_DIRECTORY_DEPTH; depth += 1) { + const pathsToRequest: string[] = []; + for (const directory of parentDirectories) { + const entries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles); + for (const entry of entries) { + const isPersistedExpandedDirectory = + entry.kind === "directory" && persistedExpandedPaths.has(entry.path); + if (isPersistedExpandedDirectory && !restoredPathSet.has(entry.path)) { + pathsToRequest.push(entry.path); + restoredPathSet.add(entry.path); + } + } + } + if (pathsToRequest.length === 0) { + break; + } + + const requestedDirectories = await Promise.all( + pathsToRequest.map((path) => requestDirectoryListing(path)), + ); + parentDirectories = []; + for (const directory of requestedDirectories) { + if (!directory) { + continue; + } + restoredPaths.push(directory.path); + parentDirectories.push(directory); + } + } + + return restoredPaths; +} + +export function showHiddenFilesAndRestoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles, + requestDirectoryListing, +}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise { + showHiddenFiles(); + return restoreExpandedDirectories({ + rootDirectory, + persistedExpandedPaths, + showHiddenFiles: true, + requestDirectoryListing, + }); +} + +export function reconcileRestoredExpandedPaths({ + persistedExpandedPaths, + currentExpandedPaths, + restoredExpandedPaths, +}: ReconcileRestoredExpandedPathsInput): string[] { + const reconciledPaths = new Set(restoredExpandedPaths); + + for (const path of persistedExpandedPaths) { + if (!currentExpandedPaths.has(path)) { + reconciledPaths.delete(path); + } + } + for (const path of currentExpandedPaths) { + if (!persistedExpandedPaths.has(path)) { + reconciledPaths.add(path); + } + } + + return Array.from(reconciledPaths); +} + +export function setExpandedDirectoryPath({ + currentExpandedPaths, + directoryPath, + expanded, +}: SetExpandedDirectoryPathInput): string[] { + const nextPaths = new Set(currentExpandedPaths); + if (expanded) { + nextPaths.add(directoryPath); + } else { + nextPaths.delete(directoryPath); + } + return Array.from(nextPaths); +} + +function rowsForDirectory( + directory: ExplorerDirectory, + depth: number, + sortOption: SortOption, + showHiddenFiles: boolean, +): ExplorerTreeRow[] { + const visibleEntries = filterVisibleExplorerEntries(directory.entries, showHiddenFiles); + const sortedEntries = sortExplorerEntries(visibleEntries, sortOption); + return sortedEntries.map((entry) => ({ entry, depth })); +} + +function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] { + const sorted = [...entries]; + sorted.sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === "directory" ? -1 : 1; + } + switch (sortOption) { + case "name": + return a.name.localeCompare(b.name); + case "modified": + return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); + case "size": + return b.size - a.size; + } + }); + return sorted; +} diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 0502b08206..d1bb4c239a 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -1,6 +1,10 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store"; +import { + useSessionStore, + type AgentFileExplorerState, + type ExplorerDirectory, +} from "@/stores/session-store"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; function createExplorerState(): AgentFileExplorerState { @@ -88,9 +92,9 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor async ( path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }, - ): Promise => { + ): Promise => { if (!workspaceStateKey) { - return false; + return null; } const normalizedPath = path && path.length > 0 ? path : "."; const shouldSetCurrentPath = options?.setCurrentPath ?? true; @@ -119,7 +123,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: t("workspace.fileExplorer.states.unavailable"), pendingRequest: null, })); - return false; + return null; } if (!client) { @@ -129,7 +133,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: t("workspace.terminal.hostDisconnected"), pendingRequest: null, })); - return false; + return null; } try { @@ -150,7 +154,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor return nextState; }); - return true; + return directory; } catch (error) { updateExplorerState((state) => ({ ...state, @@ -161,7 +165,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor : t("workspace.fileExplorer.errors.failedToListDirectory"), pendingRequest: null, })); - return false; + return null; } }, [client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey], diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 655cb8783d..52d2aa0cc3 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -63,6 +63,8 @@ export { selectPanelVisibility, }; +export type ExpandedPathsUpdate = string[] | ((currentPaths: string[]) => string[]); + export interface PanelState { // Mobile: React's durable target plus the generation that owns it. mobilePanel: MobilePanelSelection; @@ -104,7 +106,7 @@ export interface PanelState { // File explorer settings actions setExplorerTab: (tab: ExplorerTab) => void; setExplorerTabForCheckout: (params: ExplorerCheckoutContext & { tab: ExplorerTab }) => void; - setExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void; + setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void; setDiffExpandedPathsForWorkspace: (workspaceKey: string, paths: string[]) => void; setDiffCollapsedFoldersForWorkspace: (workspaceKey: string, dirPaths: string[]) => void; activateExplorerTabForCheckout: (checkout: ExplorerCheckoutContext) => void; @@ -253,9 +255,16 @@ export const usePanelStore = create()( return nextState; }), setExpandedPathsForWorkspace: (workspaceKey, paths) => - set((state) => ({ - expandedPathsByWorkspace: { ...state.expandedPathsByWorkspace, [workspaceKey]: paths }, - })), + set((state) => { + const currentPaths = state.expandedPathsByWorkspace[workspaceKey] ?? ["."]; + const nextPaths = typeof paths === "function" ? paths(currentPaths) : paths; + return { + expandedPathsByWorkspace: { + ...state.expandedPathsByWorkspace, + [workspaceKey]: nextPaths, + }, + }; + }), setDiffExpandedPathsForWorkspace: (workspaceKey, paths) => set((state) => ({ diffExpandedPathsByWorkspace: { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 1d61867f26..7ff1bc6a25 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -285,7 +285,7 @@ export interface ExplorerFile { modifiedAt: string; } -interface ExplorerDirectory { +export interface ExplorerDirectory { path: string; entries: ExplorerEntry[]; } From 891dd5a81316295fdcc254f5de9a11e3ba8c0ca6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 16:42:42 +0200 Subject: [PATCH 03/46] feat(providers): expose agent cwd to provider processes (#2563) --- .../src/server/agent/agent-manager.test.ts | 16 +++++++++++++++- .../server/src/server/agent/agent-manager.ts | 15 +++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index d55f1c5986..045e243324 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -35,6 +35,7 @@ import type { AgentStreamEvent, AgentTimelineItem, ImportProviderSessionInput, + ImportProviderSessionContext, ResolveAgentDefaultModeInput, } from "./agent-sdk-types.js"; import type { PaseoToolCatalog } from "./tools/types.js"; @@ -1706,6 +1707,7 @@ test("createAgent passes daemon launch env through the provider launch context", agentId: snapshot.id, env: { PASEO_AGENT_ID: snapshot.id, + PASEO_AGENT_CWD: workdir, }, }); }); @@ -2511,6 +2513,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p agentId: resumed.id, env: { PASEO_AGENT_ID: resumed.id, + PASEO_AGENT_CWD: workdir, }, }); }); @@ -2525,14 +2528,16 @@ test("importProviderSession imports the selected session without listing and pub class ImportClient extends TestAgentClient { listCalls = 0; importInput: unknown = null; + importLaunchContext: AgentLaunchContext | undefined; async listImportableSessions() { this.listCalls += 1; return []; } - async importSession(input: ImportProviderSessionInput) { + async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { this.importInput = input; + this.importLaunchContext = context.launchContext; return { session, config: { provider: "codex" as const, cwd: workdir }, @@ -2612,6 +2617,13 @@ test("importProviderSession imports the selected session without listing and pub expect(client.listCalls).toBe(0); expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir }); + expect(client.importLaunchContext).toEqual({ + agentId: imported.id, + env: { + PASEO_AGENT_ID: imported.id, + PASEO_AGENT_CWD: workdir, + }, + }); expect(imported.lifecycle).toBe("idle"); expect(imported.historyPrimed).toBe(true); expect(manager.getTimeline(imported.id)).toEqual([ @@ -2712,6 +2724,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co agentId: snapshot.id, env: { PASEO_AGENT_ID: snapshot.id, + PASEO_AGENT_CWD: workdir, }, }); @@ -2723,6 +2736,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co agentId: snapshot.id, env: { PASEO_AGENT_ID: snapshot.id, + PASEO_AGENT_CWD: workdir, }, }); }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index c18e8c902c..72fe1a7ec8 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1023,7 +1023,12 @@ export class AgentManager { const client = await this.requireAvailableClient({ provider: storedConfig.provider, }); - const launchContext = await this.buildLaunchContext(resolvedAgentId, client, options?.env); + const launchContext = await this.buildLaunchContext( + resolvedAgentId, + client, + storedConfig.cwd, + options?.env, + ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); @@ -1101,7 +1106,7 @@ export class AgentManager { `Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`, ); } - const launchContext = await this.buildLaunchContext(resolvedAgentId, client); + const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = await client.resumeSession( handle, @@ -1148,7 +1153,7 @@ export class AgentManager { }, resolvedAgentId, ); - const launchContext = await this.buildLaunchContext(resolvedAgentId, client); + const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const imported = await client.importSession( { @@ -1229,7 +1234,7 @@ export class AgentManager { provider, } as AgentSessionConfig; const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId); - const launchContext = await this.buildLaunchContext(agentId, client); + const launchContext = await this.buildLaunchContext(agentId, client, storedConfig.cwd); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const session = handle @@ -4174,6 +4179,7 @@ export class AgentManager { private async buildLaunchContext( agentId: string, client: AgentClient, + cwd: string, env?: Record, ): Promise { const context: AgentLaunchContext = { @@ -4181,6 +4187,7 @@ export class AgentManager { env: { ...env, PASEO_AGENT_ID: agentId, + PASEO_AGENT_CWD: cwd, }, }; if ( From ee3a9b013b3509769af0cb39e865d626e0ede822 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:05:59 +0200 Subject: [PATCH 04/46] Support repository search on older GitHub CLI versions (#2611) * fix(github): support repository lists from older gh versions GitHub CLI versions before 2.28 reject the visibility JSON field before returning repository data. Retry that specific failure with the older isPrivate field while preserving exact visibility on newer clients. * fix(github): stop requesting repository visibility Repository cloning does not use visibility. Leave the released response field optional and keep repository discovery on JSON fields supported by older GitHub CLI versions. * fix(github): keep the visibility enum on the wire, derive it from isPrivate Restores the required visibility enum in the search-repositories response schema. Omitting it broke old clients, which reject the whole response. The daemon still never asks gh for visibility -- that JSON field does not exist before gh 2.28 and Debian Bookworm ships 2.23. It asks for isPrivate, supported on every version, and maps it to public/private. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra Co-authored-by: Claude Opus 5 --- packages/app/src/add-project-flow/model.ts | 1 - packages/app/src/add-project-flow/options.ts | 2 -- .../app/src/components/add-project-flow.tsx | 2 +- .../services/github-repository-search.test.ts | 8 +++---- .../server/src/services/github-service.ts | 23 ++++++++----------- 5 files changed, 14 insertions(+), 22 deletions(-) diff --git a/packages/app/src/add-project-flow/model.ts b/packages/app/src/add-project-flow/model.ts index 16b80e9c49..3cfd7691a2 100644 --- a/packages/app/src/add-project-flow/model.ts +++ b/packages/app/src/add-project-flow/model.ts @@ -14,7 +14,6 @@ export interface GithubRepositoryChoice { cloneUrl: string; cloneProtocol?: "https" | "ssh"; description: string | null; - visibility: string | null; updatedAt: string | null; } diff --git a/packages/app/src/add-project-flow/options.ts b/packages/app/src/add-project-flow/options.ts index 1c9fec5bf3..c1d6ce9e4e 100644 --- a/packages/app/src/add-project-flow/options.ts +++ b/packages/app/src/add-project-flow/options.ts @@ -101,7 +101,6 @@ export function buildManualGithubRepositoryChoices(query: string): GithubReposit nameWithOwner: identity?.repo ?? remoteName, cloneUrl: repo, description: "Clone this repository URL", - visibility: null, updatedAt: null, }, ]; @@ -116,7 +115,6 @@ export function buildManualGithubRepositoryChoices(query: string): GithubReposit cloneUrl: nameWithOwner, cloneProtocol, description: `Clone owner/repo via ${cloneProtocol.toUpperCase()}`, - visibility: null, updatedAt: null, })); } diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 3078062a77..45425fde89 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -644,7 +644,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { title: repository.cloneProtocol ? `${repository.nameWithOwner} via ${repository.cloneProtocol.toUpperCase()}` : repository.nameWithOwner, - subtitle: repository.description ?? repository.visibility, + subtitle: repository.description, icon: Github, testID: `add-project-flow-repository-${repository.id}`, select: () => diff --git a/packages/server/src/services/github-repository-search.test.ts b/packages/server/src/services/github-repository-search.test.ts index 94e2ee068f..d324aab16c 100644 --- a/packages/server/src/services/github-repository-search.test.ts +++ b/packages/server/src/services/github-repository-search.test.ts @@ -30,7 +30,7 @@ describe("GitHub repository search", () => { name: " paseo ", nameWithOwner: " getpaseo/paseo ", description: null, - visibility: "PUBLIC", + isPrivate: false, updatedAt: "2026-07-15T12:00:00Z", sshUrl: " git@github.com:getpaseo/paseo.git ", url: "https://github.com/getpaseo/paseo", @@ -62,7 +62,7 @@ describe("GitHub repository search", () => { "repo", "list", "--json", - "id,name,nameWithOwner,description,visibility,updatedAt,sshUrl,url", + "id,name,nameWithOwner,description,isPrivate,updatedAt,sshUrl,url", "--limit", "8", ], @@ -83,7 +83,7 @@ describe("GitHub repository search", () => { name: "private-repo", fullName: "octo/private-repo", description: "Private project", - visibility: "PRIVATE", + isPrivate: true, updatedAt: "2026-07-14T08:00:00Z", url: "https://github.com/octo/private-repo", }, @@ -115,7 +115,7 @@ describe("GitHub repository search", () => { "repos", "private project", "--json", - "id,name,fullName,description,visibility,updatedAt,url", + "id,name,fullName,description,isPrivate,updatedAt,url", "--sort", "updated", "--order", diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index e2d82db883..d43916cb6b 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -147,7 +147,7 @@ const GitHubRepositoryListItemSchema = z.object({ name: z.string(), nameWithOwner: z.string(), description: z.string().nullable().optional(), - visibility: z.string(), + isPrivate: z.boolean(), updatedAt: z.string(), sshUrl: z.string(), url: z.string(), @@ -158,7 +158,7 @@ const GitHubRepositorySearchItemSchema = z.object({ name: z.string(), fullName: z.string(), description: z.string().nullable().optional(), - visibility: z.string(), + isPrivate: z.boolean(), updatedAt: z.string(), url: z.string(), }); @@ -1287,7 +1287,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G "repo", "list", "--json", - "id,name,nameWithOwner,description,visibility,updatedAt,sshUrl,url", + "id,name,nameWithOwner,description,isPrivate,updatedAt,sshUrl,url", "--limit", String(limit), ], @@ -1305,7 +1305,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G "repos", query, "--json", - "id,name,fullName,description,visibility,updatedAt,url", + "id,name,fullName,description,isPrivate,updatedAt,url", "--sort", "updated", "--order", @@ -2315,7 +2315,7 @@ function normalizeRepositorySummary(repository: { name: string; nameWithOwner: string; description?: string | null; - visibility: string; + isPrivate: boolean; updatedAt: string; cloneUrl: string; }): GitHubRepositorySummary { @@ -2328,20 +2328,15 @@ function normalizeRepositorySummary(repository: { name: repository.name.trim(), nameWithOwner, description: repository.description ?? null, - visibility: normalizeRepositoryVisibility(repository.visibility), + // We query isPrivate, not visibility: `gh repo list --json visibility` is unsupported before + // gh 2.28 (Debian Bookworm ships 2.23), while isPrivate works on every version. The wire schema + // requires the visibility enum, so old clients still get a value they can parse. + visibility: repository.isPrivate ? "private" : "public", updatedAt: repository.updatedAt, cloneUrl: repository.cloneUrl.trim(), }; } -function normalizeRepositoryVisibility(visibility: string): GitHubRepositorySummary["visibility"] { - const normalized = visibility.toLowerCase(); - if (normalized === "public" || normalized === "private" || normalized === "internal") { - return normalized; - } - throw new Error(`Unknown GitHub repository visibility: ${visibility}`); -} - function toPullRequestCheckoutTarget( parsed: z.infer, ): PullRequestCheckoutTarget { From 16bbead5a47797cd5823695e6edf5044247c7b4a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 18:11:47 +0200 Subject: [PATCH 05/46] Stop Windows server CI from flaking on stderr timeouts (#2613) * test(server): make stderr timeout coverage deterministic The timeout assertion raced Windows child-process scheduling and stderr pipe delivery. Buffer stderr before starting the timed request so the test isolates the transport behavior it intends to verify. * test(server): synchronize stderr timeout test in parent Use the injected process adapter to populate stderr through the parent-side stream before starting the timeout. This removes cross-pipe ordering from the test. --- .../agent/providers/jsonl-rpc-process.test.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts index 26e33043f9..6d5928ac6c 100644 --- a/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts +++ b/packages/server/src/server/agent/providers/jsonl-rpc-process.test.ts @@ -1,3 +1,6 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; import pino from "pino"; import { describe, expect, test } from "vitest"; @@ -42,7 +45,6 @@ readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on("line return; } if (command.type === "hang") { - process.stderr.write("still waiting"); return; } if (command.type === "exit") { @@ -52,7 +54,33 @@ readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on("line }); `; -function startProcess(): JsonlRpcProcess { +interface InMemoryChildProcess extends ChildProcessWithoutNullStreams { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; +} + +interface StartProcessOptions { + child?: ChildProcessWithoutNullStreams; +} + +function createInMemoryChildProcess(): InMemoryChildProcess { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + }) as InMemoryChildProcess; + child.kill = ((signal?: NodeJS.Signals | number) => { + queueMicrotask(() => child.emit("exit", null, signal ?? null)); + return true; + }) as ChildProcessWithoutNullStreams["kill"]; + return child; +} + +function startProcess(options: StartProcessOptions = {}): JsonlRpcProcess { + const child = options.child; return new JsonlRpcProcess({ launch: { command: process.execPath, @@ -61,6 +89,7 @@ function startProcess(): JsonlRpcProcess { env: { JSONL_RPC_TEST_VALUE: "resolved-env" }, }, logger: pino({ level: "silent" }), + ...(child ? { spawn: () => child } : {}), }); } @@ -127,10 +156,11 @@ describe("JsonlRpcProcess", () => { }); test("includes buffered stderr when a request times out", async () => { - const transport = startProcess(); + const child = createInMemoryChildProcess(); + const transport = startProcess({ child }); try { - await transport.request({ type: "echo", value: "ready" }); + child.stderr.write("still waiting"); await expect(transport.request({ type: "hang" }, 50)).rejects.toThrow( "JSONL RPC request timed out for hang\nstill waiting", From 9e5accee3c688e2a766f56c76d24b6b03a07b72d Mon Sep 17 00:00:00 2001 From: nikuscs Date: Wed, 29 Jul 2026 17:48:05 +0100 Subject: [PATCH 06/46] fix: reject oversized checkout diffs (#2488) * fix: reject oversized checkout diffs * fix(diff): keep large previews from disconnecting clients The physical socket limit is an OOM backstop, not a frame-size contract. Bound structured diff construction incrementally and report oversized previews without closing the client's socket. * fix(app): preserve oversized diff state * fix(diff): stay within relay frame limit --------- Co-authored-by: Mohamed Boudra --- packages/app/src/data/push-router.test.ts | 13 +- packages/app/src/data/push-router.ts | 6 + packages/app/src/git/diff-pane.tsx | 8 + packages/app/src/git/diff-too-large-state.tsx | 49 ++++ packages/app/src/git/use-diff-query.ts | 1 + packages/app/src/git/use-working-diff.ts | 2 + packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + packages/app/src/panels/diff-panel.tsx | 4 + packages/client/src/daemon-client.ts | 1 + packages/protocol/src/messages.ts | 2 + packages/relay/src/encrypted-channel.ts | 14 +- packages/relay/src/index.ts | 8 +- .../src/server/checkout-diff-manager.ts | 8 + .../checkout-diff-subscription.e2e.test.ts | 31 +++ .../src/server/websocket/physical-socket.ts | 7 +- .../server/src/utils/checkout-git.test.ts | 15 ++ packages/server/src/utils/checkout-git.ts | 217 ++++++++++++------ 24 files changed, 321 insertions(+), 81 deletions(-) create mode 100644 packages/app/src/git/diff-too-large-state.tsx diff --git a/packages/app/src/data/push-router.test.ts b/packages/app/src/data/push-router.test.ts index 466ffabffa..157d29c699 100644 --- a/packages/app/src/data/push-router.test.ts +++ b/packages/app/src/data/push-router.test.ts @@ -204,25 +204,34 @@ describe("server data push router", () => { fake.emit({ type: "subscribe_checkout_diff_response", - payload: { subscriptionId, cwd, files: [], error: null, requestId: "diff-1" }, + payload: { + subscriptionId, + cwd, + files: [], + error: null, + diffTooLarge: true, + requestId: "diff-1", + }, }); expect(queryClient.getQueryData(queryKey)).toEqual({ cwd, files: [], error: null, + diffTooLarge: true, requestId: "diff-1", }); fake.emit({ type: "checkout_diff_update", - payload: { subscriptionId, cwd, files: [], error: null }, + payload: { subscriptionId, cwd, files: [], error: null, diffTooLarge: true }, }); expect(queryClient.getQueryData(queryKey)).toEqual({ cwd, files: [], error: null, + diffTooLarge: true, requestId: `subscription:${subscriptionId}`, }); diff --git a/packages/app/src/data/push-router.ts b/packages/app/src/data/push-router.ts index b4dca8f11a..aa7e55b1c4 100644 --- a/packages/app/src/data/push-router.ts +++ b/packages/app/src/data/push-router.ts @@ -420,6 +420,9 @@ function applyCheckoutDiffUpdate(input: { cwd: input.message.payload.cwd, files: orderCheckoutDiffFiles(input.message.payload.files), error: input.message.payload.error, + ...(input.message.payload.diffTooLarge !== undefined + ? { diffTooLarge: input.message.payload.diffTooLarge } + : {}), requestId: `subscription:${input.message.payload.subscriptionId}`, }, }); @@ -440,6 +443,9 @@ function applyCheckoutDiffSubscribeResponse(input: { cwd: input.message.payload.cwd, files: orderCheckoutDiffFiles(input.message.payload.files), error: input.message.payload.error, + ...(input.message.payload.diffTooLarge !== undefined + ? { diffTooLarge: input.message.payload.diffTooLarge } + : {}), requestId: input.message.payload.requestId, }, }); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index a6fc618614..9ddeeee43a 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -119,6 +119,7 @@ import { type InlineReviewActions, } from "@/review"; import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; +import { DiffTooLargeState } from "@/git/diff-too-large-state"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -1755,6 +1756,7 @@ interface DiffBodyContentProps { notGit: boolean; isDiffLoading: boolean; diffErrorMessage: string | null; + diffTooLarge: boolean; hasChanges: boolean; emptyMessage: string; children: ReactElement; @@ -1768,6 +1770,7 @@ function DiffBodyContent({ notGit, isDiffLoading, diffErrorMessage, + diffTooLarge, hasChanges, emptyMessage, children, @@ -1803,6 +1806,9 @@ function DiffBodyContent({ ); } + if (diffTooLarge) { + return ; + } if (diffErrorMessage) { return ( @@ -2693,6 +2699,7 @@ export function GitDiffPane({ selectBase: handleSelectBase, files, diffPayloadError, + diffTooLarge, isDiffLoading, reviewActions, reviewAttachment, @@ -2855,6 +2862,7 @@ export function GitDiffPane({ notGit={notGit} isDiffLoading={isDiffLoading} diffErrorMessage={diffErrorMessage} + diffTooLarge={diffTooLarge} hasChanges={hasChanges} emptyMessage={emptyMessage} checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")} diff --git a/packages/app/src/git/diff-too-large-state.tsx b/packages/app/src/git/diff-too-large-state.tsx new file mode 100644 index 0000000000..d478125979 --- /dev/null +++ b/packages/app/src/git/diff-too-large-state.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { FileWarning } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +const ThemedFileWarning = withUnistyles(FileWarning); +const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +export function DiffTooLargeState() { + const { t } = useTranslation(); + + return ( + + + + {t("workspace.git.diff.previewTooLargeTitle")} + {t("workspace.git.diff.previewTooLargeDescription")} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + paddingTop: theme.spacing[16], + }, + copy: { + alignItems: "center", + gap: theme.spacing[1], + maxWidth: 360, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + textAlign: "center", + }, + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + textAlign: "center", + }, +})); diff --git a/packages/app/src/git/use-diff-query.ts b/packages/app/src/git/use-diff-query.ts index ca3a0b5a87..93ea35c70b 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -92,6 +92,7 @@ export function useCheckoutDiffQuery({ return { files: payload?.files ?? [], payloadError, + diffTooLarge: payload?.diffTooLarge === true, isLoading: payload === null && enabled && isConnected, isFetching: false, isError: Boolean(payloadError), diff --git a/packages/app/src/git/use-working-diff.ts b/packages/app/src/git/use-working-diff.ts index 55e8709bd7..494a0f95a2 100644 --- a/packages/app/src/git/use-working-diff.ts +++ b/packages/app/src/git/use-working-diff.ts @@ -79,6 +79,7 @@ export function useWorkingDiff({ const { files, payloadError: diffPayloadError, + diffTooLarge, isLoading: isDiffLoading, } = useCheckoutDiffQuery({ serverId, @@ -123,6 +124,7 @@ export function useWorkingDiff({ selectBase, files, diffPayloadError, + diffTooLarge, isDiffLoading, reviewActions, reviewAttachment, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index e275c72dc2..48d82e4257 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -764,6 +764,8 @@ export const ar: TranslationResources = { closeChangesTab: "إغلاق علامة تبويب التغييرات", binaryFile: "ملف ثنائي", tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه", + previewTooLargeTitle: "هذا الفرق كبير جدًا بحيث لا يمكن معاينته", + previewTooLargeDescription: "ضيّق نطاق المقارنة لمعاينتها هنا", unified: "الفرق الموحدة", split: "فرق جنبًا إلى جنب", switchToUnified: "التبديل إلى الفرق الموحد", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 8842cd9da3..a7fd2d1aaa 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -774,6 +774,8 @@ export const en = { closeChangesTab: "Close Changes tab", binaryFile: "Binary file", tooLarge: "Diff too large to display", + previewTooLargeTitle: "This diff is too large to preview", + previewTooLargeDescription: "Narrow the comparison to preview it here", unified: "Unified diff", split: "Side-by-side diff", switchToUnified: "Switch to unified diff", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7dd54d9b9a..1abd7cc059 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -795,6 +795,8 @@ export const es: TranslationResources = { closeChangesTab: "Cerrar la pestaña Cambios", binaryFile: "archivo binario", tooLarge: "La diferencia es demasiado grande para mostrarse", + previewTooLargeTitle: "Este diff es demasiado grande para previsualizarlo", + previewTooLargeDescription: "Reduce la comparación para previsualizarla aquí", unified: "Diferencia unificada", split: "Diferencia de lado a lado", switchToUnified: "Cambiar a diferencia unificada", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 821604b50a..58dc392cf1 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -794,6 +794,8 @@ export const fr: TranslationResources = { closeChangesTab: "Fermer l'onglet Modifications", binaryFile: "Fichier binaire", tooLarge: "Diff trop grand pour être affiché", + previewTooLargeTitle: "Ce diff est trop volumineux pour être prévisualisé", + previewTooLargeDescription: "Réduisez la comparaison pour la prévisualiser ici", unified: "Différentiel unifié", split: "Différent côte à côte", switchToUnified: "Passer au diff unifié", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 821e92416a..0be4e6e982 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -775,6 +775,8 @@ export const ja: TranslationResources = { closeChangesTab: "変更タブを閉じる", binaryFile: "バイナリファイル", tooLarge: "差分が大きすぎて表示できません", + previewTooLargeTitle: "この差分は大きすぎるためプレビューできません", + previewTooLargeDescription: "比較範囲を狭めると、ここでプレビューできます", unified: "ユニファイド差分", split: "左右比較", switchToUnified: "ユニファイド差分に切り替え", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b32cb791bf..51c9553fac 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -786,6 +786,8 @@ export const ptBR: TranslationResources = { closeChangesTab: "Fechar a aba Alterações", binaryFile: "Arquivo binário", tooLarge: "Diff grande demais para exibir", + previewTooLargeTitle: "Este diff é grande demais para visualizar", + previewTooLargeDescription: "Reduza a comparação para visualizá-la aqui", unified: "Diff unificado", split: "Diff lado a lado", switchToUnified: "Alternar para diff unificado", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f175927cf9..d546a92181 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -786,6 +786,8 @@ export const ru: TranslationResources = { closeChangesTab: "Закрыть вкладку «Изменения»", binaryFile: "Бинарный файл", tooLarge: "Разница слишком велика для отображения", + previewTooLargeTitle: "Этот diff слишком велик для предпросмотра", + previewTooLargeDescription: "Сузьте сравнение, чтобы просмотреть его здесь", unified: "Единый дифференциал", split: "Параллельная разница", switchToUnified: "Переключить на объединенный diff", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 52579675c8..eb22734bdf 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -756,6 +756,8 @@ export const zhCN: TranslationResources = { closeChangesTab: "关闭“更改”标签页", binaryFile: "二进制文件", tooLarge: "Diff 过大,无法显示", + previewTooLargeTitle: "此 diff 过大,无法预览", + previewTooLargeDescription: "缩小比较范围即可在此预览", unified: "Unified diff", split: "Side-by-side diff", switchToUnified: "切换到统一差异视图", diff --git a/packages/app/src/panels/diff-panel.tsx b/packages/app/src/panels/diff-panel.tsx index df14c3cb99..f315dd5bca 100644 --- a/packages/app/src/panels/diff-panel.tsx +++ b/packages/app/src/panels/diff-panel.tsx @@ -17,6 +17,7 @@ import { resolveDiffLayout, SharedDiffView, } from "@/git/diff-pane"; +import { DiffTooLargeState } from "@/git/diff-too-large-state"; import { useCommitDiffFiles } from "@/git/use-diff-files"; import { usePublishWorkingDiffAttachment, useWorkingDiff } from "@/git/use-working-diff"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; @@ -120,6 +121,9 @@ function WorkingDiffBody({ if (workingDiff.notGit) { return ; } + if (workingDiff.diffTooLarge) { + return ; + } if (workingDiff.diffPayloadError) { return ( diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 8e67ea4b5a..ecfd68a472 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3490,6 +3490,7 @@ export class DaemonClient { cwd: payload.cwd, files: payload.files, error: payload.error, + diffTooLarge: payload.diffTooLarge, requestId: payload.requestId, }; } finally { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index c269321f61..7c1e2bc06d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -4128,6 +4128,8 @@ const CheckoutDiffSubscriptionPayloadSchema = z.object({ cwd: z.string(), files: z.array(ParsedDiffFileSchema), error: CheckoutErrorSchema.nullable(), + // COMPAT(diffTooLarge): added in v0.2.4, keep optional until the daemon floor is v0.2.4. + diffTooLarge: z.boolean().optional(), }); export const SubscribeCheckoutDiffResponseSchema = z.object({ diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts index de953616ad..0139cc13eb 100644 --- a/packages/relay/src/encrypted-channel.ts +++ b/packages/relay/src/encrypted-channel.ts @@ -118,6 +118,15 @@ function buildInvalidHelloError(rawText: string, parsed?: unknown): Error { const HANDSHAKE_RETRY_MS = 1000; const MAX_PENDING_SENDS = 200; const REHANDSHAKE_KEY_MISMATCH_CLOSE_CODE = 1008; +const ENCRYPTED_PAYLOAD_OVERHEAD_BYTES = 40; + +export function base64EncryptedWireByteLength(plaintextBytes: number): number { + return 4 * Math.ceil((plaintextBytes + ENCRYPTED_PAYLOAD_OVERHEAD_BYTES) / 3); +} + +export function maxBase64EncryptedPlaintextByteLength(wireBytes: number): number { + return Math.floor(wireBytes / 4) * 3 - ENCRYPTED_PAYLOAD_OVERHEAD_BYTES; +} const REHANDSHAKE_KEY_MISMATCH_CLOSE_REASON = "E2EE re-handshake key mismatch"; interface TimeoutWithUnref { @@ -474,11 +483,12 @@ export class EncryptedChannel { } outboundWireByteLength(data: string | ArrayBuffer): number { - const encryptedBytes = utf8ByteLength(data) + 40; + const plaintextBytes = utf8ByteLength(data); + const encryptedBytes = plaintextBytes + ENCRYPTED_PAYLOAD_OVERHEAD_BYTES; if (this.options.binaryCiphertext && data instanceof ArrayBuffer) { return encryptedBytes; } - return 4 * Math.ceil(encryptedBytes / 3); + return base64EncryptedWireByteLength(plaintextBytes); } private async flushPendingSends(): Promise { diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts index 79b575c711..b9f455e0f1 100644 --- a/packages/relay/src/index.ts +++ b/packages/relay/src/index.ts @@ -9,5 +9,11 @@ export { decrypt, } from "./crypto.js"; -export { createClientChannel, createDaemonChannel, EncryptedChannel } from "./encrypted-channel.js"; +export { + base64EncryptedWireByteLength, + createClientChannel, + createDaemonChannel, + EncryptedChannel, + maxBase64EncryptedPlaintextByteLength, +} from "./encrypted-channel.js"; export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js"; diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index b4f90a62a2..01e7a8284f 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -208,6 +208,14 @@ export class CheckoutDiffManager { ? { force: true, reason: options.reason ?? "checkout-diff-refresh" } : undefined, ); + if (diffResult.diffTooLarge) { + return { + cwd, + files: [], + diffTooLarge: true, + error: toCheckoutError(new Error("Diff too large to display")), + }; + } const files = [...(diffResult.structured ?? [])]; files.sort((a, b) => { if (a.path === b.path) return 0; diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts index 43a3479018..7ce8cbb3a3 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-subscription.e2e.test.ts @@ -138,3 +138,34 @@ test("pushes updates when subscribed from a subdirectory and files change outsid rmSync(cwd, { recursive: true, force: true }); } }, 60000); + +test("keeps the socket usable after rejecting an oversized structured diff", async () => { + const cwd = tmpCwd(); + + try { + initGitRepo(cwd); + commitFile(cwd, "large-a.js", "const value = 0;\n"); + commitFile(cwd, "large-b.js", "const value = 0;\n"); + const denseExpression = `const value = ${"a+".repeat(450_000)}a;\n`; + writeFileSync(path.join(cwd, "large-a.js"), denseExpression); + writeFileSync(path.join(cwd, "large-b.js"), denseExpression); + + const initial = await ctx.client.subscribeCheckoutDiff( + cwd, + { mode: "uncommitted" }, + { subscriptionId: "oversized-checkout-diff" }, + ); + + expect(initial).toMatchObject({ + cwd, + files: [], + diffTooLarge: true, + error: { code: "UNKNOWN" }, + }); + + const status = await ctx.client.getCheckoutStatus(cwd); + expect(status).toMatchObject({ cwd, isGit: true }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}, 120000); diff --git a/packages/server/src/server/websocket/physical-socket.ts b/packages/server/src/server/websocket/physical-socket.ts index 491a7c612d..abb0407e9a 100644 --- a/packages/server/src/server/websocket/physical-socket.ts +++ b/packages/server/src/server/websocket/physical-socket.ts @@ -1,6 +1,7 @@ -// Terminal streams begin snapshot catch-up at 4 MiB. The physical socket gets -// another 4 MiB to recover before the daemon enforces the hard memory bound. -export const MAX_PHYSICAL_SOCKET_BUFFERED_BYTES = 8 * 1024 * 1024; +// OOM backstop for a socket whose client stopped draining. A daemon normally has +// 1-10 physical sockets (tens at the outside), so 64 MiB bounds abandoned queues +// without treating ordinary large frames as a protocol or frame-size violation. +export const MAX_PHYSICAL_SOCKET_BUFFERED_BYTES = 64 * 1024 * 1024; // Current clients ping every 10 seconds. Four delayed cycles fit inside the // lease without making an abandoned application socket linger for minutes. export const APPLICATION_SOCKET_LEASE_MS = 45_000; diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 2720b0d0ae..110d3599f6 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -14,11 +14,13 @@ import { join } from "path"; import { win32 } from "node:path"; import { tmpdir } from "os"; import pino from "pino"; +import { base64EncryptedWireByteLength } from "@getpaseo/relay"; import { __resetCheckoutShortstatCacheForTests, __resetPullRequestStatusCacheForTests, __setPullRequestStatusCacheTtlForTests, commitAll, + CHECKOUT_DIFF_MAX_STRUCTURED_BYTES, createPullRequest, getCachedCheckoutShortstat, getCheckoutSnapshotFacts, @@ -1333,6 +1335,19 @@ const x = 1; ); }); + it("keeps the structured diff cap below the relay frame limit", () => { + const relayFrameBytes = 32 * 1024 * 1024; + const frameEnvelopeHeadroomBytes = 1024 * 1024; + const diffWireBytes = base64EncryptedWireByteLength(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES); + const maximumFrameWireBytes = base64EncryptedWireByteLength( + CHECKOUT_DIFF_MAX_STRUCTURED_BYTES + frameEnvelopeHeadroomBytes, + ); + + expect(CHECKOUT_DIFF_MAX_STRUCTURED_BYTES).toBe(24_117_208); + expect(diffWireBytes).toBeLessThan(relayFrameBytes); + expect(maximumFrameWireBytes).toBe(relayFrameBytes); + }); + it("marks tracked generated one-line diffs as too_large by content size", async () => { writeFileSync(join(repoDir, "generated.js"), `const data = "old";\n`); execFileSync("git", ["add", "generated.js"], { cwd: repoDir }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 6d09eee0df..d2e7ae29cc 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -3,9 +3,14 @@ import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; +import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay"; import type { Logger } from "pino"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; -import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; +import { + highlightDiffWithFileContent, + parseAndHighlightDiff, + parseDiff, +} from "../server/utils/diff-highlighter.js"; import { parseGitHubRepoFromRemote } from "../server/workspace-git-metadata.js"; import { createGitHubService } from "../services/github-service.js"; import type { @@ -583,6 +588,7 @@ function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }) } const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB +const TRACKED_DIFF_BATCH_SIZE = 8; const EMPTY_TREE_OBJECT_ID = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; function isUnbornHeadDiffError(error: unknown): boolean { @@ -759,10 +765,9 @@ export type CheckoutStatusGit = CheckoutStatusGitNonPaseo | CheckoutStatusGitPas export type CheckoutStatusResult = CheckoutStatus | CheckoutStatusGit; -export interface CheckoutDiffResult { - diff: string; - structured?: ParsedDiffFile[]; -} +export type CheckoutDiffResult = + | { diff: string; structured?: ParsedDiffFile[]; diffTooLarge?: false } + | { diff: ""; structured: []; diffTooLarge: true }; export interface CheckoutDiffCompare { mode: "uncommitted" | "base"; @@ -1823,6 +1828,37 @@ export async function getCheckoutSnapshotFacts( const PER_FILE_DIFF_MAX_BYTES = 1024 * 1024; // 1MB const TOTAL_DIFF_MAX_BYTES = 2 * 1024 * 1024; // 2MB +const RELAY_MAX_FRAME_BYTES = 32 * 1024 * 1024; +const CHECKOUT_DIFF_FRAME_HEADROOM_BYTES = 1024 * 1024; +// Temporary until diffs load lazily per file. The Paseo relay's 32 MiB frame limit is +// binding: string frames are encrypted and base64-encoded. Reserve 1 MiB plaintext for +// the surrounding WebSocket JSON envelope after inverting that exact wire expansion. +export const CHECKOUT_DIFF_MAX_STRUCTURED_BYTES = + maxBase64EncryptedPlaintextByteLength(RELAY_MAX_FRAME_BYTES) - CHECKOUT_DIFF_FRAME_HEADROOM_BYTES; + +interface StructuredDiffAccumulator { + files: ParsedDiffFile[]; + serializedBytes: number; +} + +function createStructuredDiffAccumulator(): StructuredDiffAccumulator { + return { files: [], serializedBytes: Buffer.byteLength("[]", "utf8") }; +} + +function appendStructuredFile( + structured: StructuredDiffAccumulator, + file: ParsedDiffFile, +): boolean { + const separatorBytes = structured.files.length > 0 ? 1 : 0; + const fileBytes = Buffer.byteLength(JSON.stringify(file), "utf8"); + const nextBytes = structured.serializedBytes + separatorBytes + fileBytes; + if (nextBytes > CHECKOUT_DIFF_MAX_STRUCTURED_BYTES) { + return false; + } + structured.files.push(file); + structured.serializedBytes = nextBytes; + return true; +} const UNTRACKED_BINARY_SNIFF_BYTES = 16 * 1024; async function isLikelyBinaryFile(absolutePath: string): Promise { @@ -2543,27 +2579,62 @@ export function warmCheckoutShortstatInBackground( interface AppendStructuredTrackedDiffsInput { cwd: string; trackedChanges: CheckoutFileChange[]; - trackedChangeByPath: Map; trackedNumstatByPath: Map; trackedPlaceholderByPath: Map; trackedDiffText: string; refsForDiff: CheckoutDiffRefs; ignoreWhitespace: boolean; - structured: ParsedDiffFile[]; - appendDiff: (text: string) => void; + structured: StructuredDiffAccumulator; appendTrackedPlaceholderComment: ( change: CheckoutFileChange, status: "binary" | "too_large", ) => void; } +async function buildHighlightedTrackedDiffFile(input: { + cwd: string; + change: CheckoutFileChange; + parsedFile: ParsedDiffFile; + refsForDiff: CheckoutDiffRefs; +}): Promise { + const { cwd, change, parsedFile, refsForDiff } = input; + const refPath = change.oldPath ?? change.path; + const [oldFileContent, newFileContent] = await Promise.all([ + change.isNew ? null : readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath), + refsForDiff.targetRef ? readGitFileContentAtRef(cwd, refsForDiff.targetRef, change.path) : null, + ]); + const highlightedFile = await highlightDiffWithFileContent(parsedFile, cwd, { + oldFileContent, + newFileContent, + }); + return { + ...highlightedFile, + path: change.path, + isNew: change.isNew, + isDeleted: change.isDeleted, + status: "ok", + }; +} + +function isWhitespaceOnlyTrackedChange(input: { + change: CheckoutFileChange; + stat: FileStat; + ignoreWhitespace: boolean; +}): boolean { + const { change, stat, ignoreWhitespace } = input; + return ( + ignoreWhitespace && + change.status.startsWith("M") && + (!stat || (!stat.isBinary && stat.additions === 0 && stat.deletions === 0)) + ); +} + async function appendStructuredTrackedDiffs( input: AppendStructuredTrackedDiffsInput, -): Promise { +): Promise { const { cwd, trackedChanges, - trackedChangeByPath, trackedNumstatByPath, trackedPlaceholderByPath, trackedDiffText, @@ -2573,36 +2644,19 @@ async function appendStructuredTrackedDiffs( appendTrackedPlaceholderComment, } = input; - const parsedTrackedFiles = - trackedDiffText.length > 0 - ? await parseAndHighlightDiff(trackedDiffText, cwd, { - getOldFileContent: async (file) => { - const change = trackedChangeByPath.get(file.path); - if (!change || change.isNew) { - return null; - } - const refPath = change.oldPath ?? change.path; - return readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath); - }, - getNewFileContent: async (file) => { - if (!refsForDiff.targetRef) { - return null; - } - return readGitFileContentAtRef(cwd, refsForDiff.targetRef, file.path); - }, - }) - : []; + const parsedTrackedFiles = trackedDiffText.length > 0 ? parseDiff(trackedDiffText) : []; const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file])); for (const change of trackedChanges) { const placeholder = trackedPlaceholderByPath.get(change.path); if (placeholder) { - structured.push( - buildPlaceholderParsedDiffFile(change, { - status: placeholder.status, - stat: placeholder.stat, - }), - ); + const file = buildPlaceholderParsedDiffFile(change, { + status: placeholder.status, + stat: placeholder.stat, + }); + if (!appendStructuredFile(structured, file)) { + return false; + } appendTrackedPlaceholderComment(change, placeholder.status); continue; } @@ -2610,28 +2664,26 @@ async function appendStructuredTrackedDiffs( const stat = trackedNumstatByPath.get(change.path) ?? null; const parsedFile = parsedTrackedByPath.get(change.path); if (parsedFile) { - structured.push({ - ...parsedFile, - path: change.path, - isNew: change.isNew, - isDeleted: change.isDeleted, - status: "ok", + const file = await buildHighlightedTrackedDiffFile({ + cwd, + change, + parsedFile, + refsForDiff, }); + if (!appendStructuredFile(structured, file)) { + return false; + } continue; } // `git diff -w --name-status` can still report a modified path even when the // whitespace-filtered patch and numstat are both empty. Skip emitting a // structured placeholder in that case so whitespace-only edits truly disappear. - if ( - ignoreWhitespace && - change.status.startsWith("M") && - (!stat || (!stat.isBinary && stat.additions === 0 && stat.deletions === 0)) - ) { + if (isWhitespaceOnlyTrackedChange({ change, stat, ignoreWhitespace })) { continue; } - structured.push({ + const file = { path: change.path, isNew: change.isNew, isDeleted: change.isDeleted, @@ -2639,8 +2691,13 @@ async function appendStructuredTrackedDiffs( deletions: stat?.deletions ?? 0, hunks: [], status: "ok", - }); + } satisfies ParsedDiffFile; + if (!appendStructuredFile(structured, file)) { + return false; + } } + + return true; } interface ProcessUntrackedChangeInput { @@ -2648,11 +2705,11 @@ interface ProcessUntrackedChangeInput { change: CheckoutFileChange; ignoreWhitespace: boolean; includeStructured: boolean; - structured: ParsedDiffFile[]; + structured: StructuredDiffAccumulator; appendDiff: (text: string) => void; } -async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promise { +async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promise { const { cwd, change, ignoreWhitespace, includeStructured, structured, appendDiff } = input; const { text, truncated, stat } = await getUntrackedDiffText(cwd, change, ignoreWhitespace); @@ -2664,19 +2721,33 @@ async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promi } else { appendDiff(text); } - return; + return true; } if (stat?.isBinary) { - structured.push(buildPlaceholderParsedDiffFile(change, { status: "binary", stat })); + if ( + !appendStructuredFile( + structured, + buildPlaceholderParsedDiffFile(change, { status: "binary", stat }), + ) + ) { + return false; + } appendDiff(`# ${change.path}: binary diff omitted\n`); - return; + return true; } if (truncated) { - structured.push(buildPlaceholderParsedDiffFile(change, { status: "too_large", stat })); + if ( + !appendStructuredFile( + structured, + buildPlaceholderParsedDiffFile(change, { status: "too_large", stat }), + ) + ) { + return false; + } appendDiff(`# ${change.path}: diff too large omitted\n`); - return; + return true; } appendDiff(text); @@ -2692,13 +2763,14 @@ async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promi hunks: [], } satisfies ParsedDiffFile); - structured.push({ + const file = { ...parsedFile, path: change.path, isNew: change.isNew, isDeleted: change.isDeleted, status: "ok", - }); + } satisfies ParsedDiffFile; + return appendStructuredFile(structured, file); } interface ProcessTrackedChangesInput { @@ -2710,7 +2782,6 @@ interface ProcessTrackedChangesInput { } interface ProcessTrackedChangesResult { - trackedChangeByPath: Map; trackedNumstatByPath: Map; trackedPlaceholderByPath: Map; trackedDiffText: string; @@ -2720,7 +2791,6 @@ async function processTrackedChanges( input: ProcessTrackedChangesInput, ): Promise { const { cwd, refsForDiff, trackedChanges, ignoreWhitespace, appendDiff } = input; - const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change])); const trackedNumstatByPath = trackedChanges.length > 0 ? await getTrackedNumstatByPath(cwd, refsForDiff, ignoreWhitespace) @@ -2742,9 +2812,10 @@ async function processTrackedChanges( let trackedDiffText = ""; let trackedDiffBytes = 0; - if (trackedDiffPaths.length > 0) { + for (let start = 0; start < trackedDiffPaths.length; start += TRACKED_DIFF_BATCH_SIZE) { + const paths = trackedDiffPaths.slice(start, start + TRACKED_DIFF_BATCH_SIZE); const trackedDiffs = await Promise.all( - trackedDiffPaths.map((path) => + paths.map((path) => getTrackedDiffTextForPath({ cwd, refsForDiff, @@ -2754,7 +2825,6 @@ async function processTrackedChanges( ), ); - const visibleTrackedDiffs: string[] = []; for (const fileDiff of trackedDiffs) { if (fileDiff.truncated) { trackedPlaceholderByPath.set(fileDiff.path, { @@ -2772,15 +2842,12 @@ async function processTrackedChanges( continue; } trackedDiffBytes += diffBytes; - visibleTrackedDiffs.push(fileDiff.text); + trackedDiffText += fileDiff.text; } - - trackedDiffText = visibleTrackedDiffs.join(""); - appendDiff(trackedDiffText); } + appendDiff(trackedDiffText); return { - trackedChangeByPath, trackedNumstatByPath, trackedPlaceholderByPath, trackedDiffText, @@ -2840,7 +2907,7 @@ export async function getCheckoutDiff( return a.path < b.path ? -1 : 1; }); - const structured: ParsedDiffFile[] = []; + const structured = createStructuredDiffAccumulator(); let diffText = ""; let diffBytes = 0; const appendDiff = (text: string) => { @@ -2881,19 +2948,20 @@ export async function getCheckoutDiff( }; if (compare.includeStructured) { - await appendStructuredTrackedDiffs({ + const didAppendTrackedDiffs = await appendStructuredTrackedDiffs({ cwd, trackedChanges, - trackedChangeByPath: trackedDiff.trackedChangeByPath, trackedNumstatByPath: trackedDiff.trackedNumstatByPath, trackedPlaceholderByPath: trackedDiff.trackedPlaceholderByPath, trackedDiffText: trackedDiff.trackedDiffText, refsForDiff: effectiveRefsForDiff, ignoreWhitespace, structured, - appendDiff, appendTrackedPlaceholderComment, }); + if (!didAppendTrackedDiffs) { + return { diff: "", structured: [], diffTooLarge: true }; + } } else { for (const change of trackedChanges) { const placeholder = trackedDiff.trackedPlaceholderByPath.get(change.path); @@ -2907,7 +2975,7 @@ export async function getCheckoutDiff( if (diffBytes >= TOTAL_DIFF_MAX_BYTES) { break; } - await processUntrackedChange({ + const didAppendUntrackedDiff = await processUntrackedChange({ cwd, change, ignoreWhitespace, @@ -2915,10 +2983,13 @@ export async function getCheckoutDiff( structured, appendDiff, }); + if (!didAppendUntrackedDiff) { + return { diff: "", structured: [], diffTooLarge: true }; + } } if (compare.includeStructured) { - return { diff: diffText, structured }; + return { diff: diffText, structured: structured.files }; } return { diff: diffText }; } From 20e46e3e3fb5d2ca10298b2f816cf7dccfdd0579 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 23:36:31 +0200 Subject: [PATCH 07/46] Restore project grouping across hosts (#2565) * fix(projects): restore cross-host grouping Random host-local project IDs replaced the remote-derived value that had also served as grouping identity. Persist a separate opaque group key and let normal boot reconciliation backfill older records without a migration. * fix(projects): keep ambiguous clones separate A shared remote can identify a project across hosts, but it cannot distinguish two independent clones on one host. Treat those keys as ambiguous so host-local project IDs remain separate. * fix(projects): preserve host-local project identity * fix(projects): strengthen grouping identity * test(app): update project host shape contract * fix(projects): preserve selected host identity * fix(projects): close grouped routing gaps * fix(projects): stabilize legacy subproject grouping * fix(projects): preserve selected project identity * fix(projects): isolate legacy host identity * fix(projects): preserve symlinked bootstrap roots * fix(projects): reconcile grouped custom names * fix(projects): stabilize settings and path identity * fix(projects): normalize default remote ports * fix(projects): harden host and remote identity * fix(projects): preserve host-scoped actions * fix(projects): preserve remote path semantics * fix(projects): model SCP remote semantics * fix(projects): keep settings on routed host * fix(projects): preserve SSH URL path semantics * fix(projects): separate identity from presentation * test(projects): use grouped identity in UI specs * fix(projects): harden grouped project routing * test(projects): expect framed settings route * fix(projects): preserve exact placement identity * fix(projects): harden grouping identity and routing * fix(projects): restore project key as grouping identity Random host-local project IDs had replaced the persisted cross-host value carried by projectKey. Keep projectId for routing while reconciliation owns and persists projectKey for grouping. * fix(projects): prevent project key collisions Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership. * test(projects): assert host-scoped project keys * fix(projects): preserve project key compatibility Resolve legacy workspace project references at the daemon boundary while keeping projectKey as the cross-host identity. Complete generic SSH normalization without collapsing distinct repositories. * fix(projects): harden project reference identity * fix(projects): retain archived reference errors * fix(projects): scope legacy paths to hosts * test(app): model persisted project keys * fix(projects): retain absolute scp users * fix(projects): resolve legacy mutation aliases * fix(projects): preserve conservative identity boundaries * fix(projects): preserve legacy placement identity * test(projects): assert legacy placement identity * test(projects): expect canonical Windows keys * fix(projects): separate bootstrap identity fields * fix(projects): scope legacy paths by host * fix(projects): stabilize opaque project identity * fix(projects): preserve remaining opaque identities * fix(projects): classify forward-slash UNC roots * fix(projects): preserve opaque selection keys * fix(projects): frame host-local project keys * fix(projects): canonicalize framed identities * test(projects): isolate repeated remote fixtures * fix(projects): keep project key as UI identity * fix(projects): reconcile hydrated project references * fix(projects): preserve archived project identity * refactor(projects): group projects from project records, not workspaces Cross-host grouping had been built by hanging a projectKey off every workspace descriptor. That scoped the key to the wrong entity: the app then had to reconstruct project identity per workspace in six modules, which forced projectKey fallbacks into RPC arguments and a server-side resolver that turned grouping keys back into project ids. A workspace has a projectId. The key belongs to the project, and grouping is a display concern, so the daemon now publishes projects over project.list plus the existing project.update channel and the app groups them in one place. Projects are addressed by host-local projectId everywhere, including the settings route. Projects persisted without a key are backfilled by ordinary boot reconciliation, with no migration. Old daemons advertise no projectList capability and simply do not group. * test(projects): seed projects alongside workspaces in fixtures Sidebar fixtures seeded workspaces without their projects, which the daemon cannot produce now that projects arrive on their own channel, so nothing rendered. Adds a seeding helper that supplies both. Also drops the provisioning test for accepting a project key where a project id belongs; that resolver was removed deliberately. * fix(projects): keep derived project identity stable Four gaps left by shrinking the key derivation: Local keys used a bare path resolve, so one directory reached by a different spelling produced a different key and reset grouped identity. Windows CI caught this with the 8.3 and long-name forms of one temp path. Canonicalize through the shared realpath-aware normalization. Subproject display names lost their selected-path input, so every subproject of a monorepo rendered under the same remote-derived name. Reconciliation only ever backfilled a missing key, so a project whose remote changed stayed grouped with hosts on the old remote. Project settings kept a disconnected host selected and rendered a dead end with no way back to an editable host. * fix(projects): read scheme remotes as URLs, not scp form An `ssh://user@host:22/org/repo` remote matched the scp-form pattern first, because `[^@]+` happily eats `ssh://git`, so the port landed in the path and the project keyed as `remote:host/22/org/repo`. Two spellings of one remote therefore never grouped. Try scp form only when there is no scheme, and drop protocol-default ports so an explicitly written `:22` matches the same remote without it. --- docs/data-model.md | 15 +- docs/glossary.md | 4 +- packages/app/e2e/add-project-flow.spec.ts | 45 +- packages/app/e2e/composer-attachments.spec.ts | 4 +- .../app/e2e/empty-project-persists.spec.ts | 34 +- packages/app/e2e/helpers/add-project-flow.ts | 11 - packages/app/e2e/helpers/hosts.ts | 63 ++ .../app/e2e/helpers/isolated-host-daemon.ts | 2 + packages/app/e2e/helpers/new-workspace.ts | 19 +- packages/app/e2e/helpers/project-grouping.ts | 131 ++++ packages/app/e2e/helpers/project-picker-ui.ts | 7 +- .../app/e2e/helpers/schedule-fake-host.ts | 20 + packages/app/e2e/helpers/seed-client.ts | 18 +- .../e2e/import-session.opencode.real.spec.ts | 8 + ...w-workspace-codex-mode-preferences.spec.ts | 4 +- .../e2e/new-workspace-composer-draft.spec.ts | 4 +- packages/app/e2e/new-workspace-entry.spec.ts | 22 +- .../new-workspace-mode-cycle-safety.spec.ts | 2 +- packages/app/e2e/new-workspace.spec.ts | 7 + .../app/e2e/project-picker-desktop.spec.ts | 16 +- .../schedules-edit-model-hydration.spec.ts | 1 + .../app/e2e/schedules-project-target.spec.ts | 11 +- packages/app/e2e/sidebar-model-b.spec.ts | 12 +- .../app/e2e/sidebar-project-grouping.spec.ts | 404 ++++++++++++ packages/app/e2e/sidebar-workspace.spec.ts | 2 +- .../app/e2e/workspace-model-restart.spec.ts | 13 +- .../app/e2e/workspace-multiplicity.spec.ts | 10 +- packages/app/src/app/_layout.tsx | 2 +- .../app/settings/projects/[projectKey].tsx | 12 - .../projects/[serverId]/[projectId].tsx | 19 + .../app/src/components/add-project-flow.tsx | 6 +- .../sidebar-workspace-list.test.tsx | 5 +- .../src/components/sidebar-workspace-list.tsx | 29 +- .../sidebar/sidebar-projection.test.ts | 1 + packages/app/src/components/ui/combobox.tsx | 9 +- ...space-shortcut-targets-subscriber.test.tsx | 25 +- .../worktree-setup-callout-policy.test.ts | 93 +-- .../worktree-setup-callout-policy.ts | 21 +- .../worktree-setup-callout-source.tsx | 3 +- packages/app/src/hooks/open-project.ts | 17 +- .../sidebar-workspaces-view-model.test.ts | 64 +- .../hooks/sidebar-workspaces-view-model.ts | 4 +- .../app/src/hooks/use-open-project.test.ts | 18 +- packages/app/src/hooks/use-open-project.ts | 12 +- packages/app/src/hooks/use-projects.test.ts | 232 ++----- packages/app/src/hooks/use-projects.ts | 8 +- .../src/hooks/use-sidebar-workspaces-list.ts | 38 +- .../app/src/projects/host-project-model.ts | 75 ++- .../app/src/projects/host-projects.test.ts | 306 +-------- packages/app/src/projects/host-projects.ts | 12 +- .../app/src/projects/project-remove.test.ts | 23 +- packages/app/src/projects/project-remove.ts | 16 +- .../app/src/projects/workspace-fetching.ts | 50 -- .../src/projects/workspace-structure.test.ts | 147 +++++ .../app/src/projects/workspace-structure.ts | 231 ++++--- .../src/runtime/directory-sync/index.test.ts | 53 +- .../app/src/runtime/directory-sync/index.ts | 50 +- .../directory-sync/workspace-replica.test.ts | 41 +- .../directory-sync/workspace-replica.ts | 34 +- .../src/runtime/replica-cache/index.test.ts | 17 +- .../app/src/runtime/replica-cache/index.ts | 42 +- .../src/schedules/schedule-form-model.test.ts | 22 + .../app/src/schedules/schedule-form-model.ts | 5 + .../schedule-project-targets.test.ts | 17 +- .../src/schedules/schedule-project-targets.ts | 2 +- .../new-workspace-initial-context.test.ts | 2 +- .../app/src/screens/new-workspace-screen.tsx | 17 +- .../new-workspace/project-selection.test.ts | 60 +- .../new-workspace/project-selection.ts | 22 +- .../project-settings-host-selection.test.ts | 30 + .../project-settings-host-selection.ts | 40 ++ .../src/screens/project-settings-screen.tsx | 113 +++- .../app/src/screens/projects-screen.test.tsx | 11 +- packages/app/src/screens/projects-screen.tsx | 16 +- packages/app/src/screens/settings-screen.tsx | 4 +- .../session-store-hooks/selectors.test.ts | 77 ++- .../stores/session-store-hooks/selectors.ts | 38 +- packages/app/src/stores/session-store.test.ts | 43 -- packages/app/src/stores/session-store.ts | 134 +--- packages/app/src/test/seed-session.ts | 41 ++ packages/app/src/utils/host-routes.test.ts | 20 +- packages/app/src/utils/host-routes.ts | 14 +- packages/app/src/utils/projects.test.ts | 620 ++---------------- packages/app/src/utils/projects.ts | 194 +++--- .../utils/sidebar-project-row-model.test.ts | 46 +- .../src/utils/sidebar-project-row-model.ts | 17 +- .../app/src/utils/sidebar-shortcuts.test.ts | 1 + .../legacy-daemon-workspaces.test.ts | 48 +- .../src/workspace/legacy-daemon-workspaces.ts | 27 +- packages/client/src/daemon-client.ts | 22 + packages/protocol/src/git-remote.test.ts | 15 + packages/protocol/src/git-remote.ts | 15 +- packages/protocol/src/messages.ts | 21 + .../src/server/agent/mcp-server.test.ts | 1 + .../src/server/agent/tools/paseo-tools.ts | 2 +- packages/server/src/server/bootstrap.ts | 5 +- .../src/server/paseo-worktree-service.test.ts | 23 +- .../server/src/server/project-key.test.ts | 141 ++++ packages/server/src/server/project-key.ts | 57 ++ packages/server/src/server/session.test.ts | 67 +- packages/server/src/server/session.ts | 35 +- .../src/server/session.workspaces.test.ts | 29 +- .../workspace-provisioning-service.test.ts | 7 +- .../workspace-provisioning-service.ts | 52 +- .../server/src/server/websocket-server.ts | 3 + .../server/src/server/workspace-directory.ts | 1 + .../workspace-reconciliation-service.test.ts | 169 ++++- .../workspace-reconciliation-service.ts | 23 +- .../workspace-registry-bootstrap-legacy.ts | 95 +-- .../workspace-registry-bootstrap.test.ts | 10 + .../server/workspace-registry-bootstrap.ts | 7 +- .../server/src/server/workspace-registry.ts | 21 +- packages/server/src/utils/path.ts | 5 + 113 files changed, 3073 insertions(+), 2038 deletions(-) create mode 100644 packages/app/e2e/helpers/project-grouping.ts create mode 100644 packages/app/e2e/sidebar-project-grouping.spec.ts delete mode 100644 packages/app/src/app/settings/projects/[projectKey].tsx create mode 100644 packages/app/src/app/settings/projects/[serverId]/[projectId].tsx delete mode 100644 packages/app/src/projects/workspace-fetching.ts create mode 100644 packages/app/src/projects/workspace-structure.test.ts create mode 100644 packages/app/src/screens/project-settings-host-selection.test.ts create mode 100644 packages/app/src/screens/project-settings-host-selection.ts create mode 100644 packages/app/src/test/seed-session.ts create mode 100644 packages/server/src/server/project-key.test.ts create mode 100644 packages/server/src/server/project-key.ts diff --git a/docs/data-model.md b/docs/data-model.md index e1841045dd..318e4835d6 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,9 +4,15 @@ Projects are allocated for the exact root selected by the caller, normalized lexically with `path.resolve` (never `realpath`). New project IDs are opaque `prj_<16 hex>` values. Existing remote-shaped or path-shaped IDs are retained as readable compatibility records and are never rekeyed. An active exact root is idempotent; archived-only matches do not resurrect an old project. Workspace `projectId` is stable membership: reconciliation may update git-derived kind and branch metadata, but never rehomes a workspace or changes a project's root, ID, or default name. -`kind` is mutable metadata, not identity. Workspace reconciliation watches active project roots and -updates only a project's `kind` and `updatedAt` when `.git` appears or disappears, preserving its -ID, root path, names, and workspace foreign keys. Attached workspaces are independently refreshed +`projectKey` is a persisted, opaque equivalence key used only to group the same logical project +across hosts. It is separate from the host-local `projectId`; today's producer prefers a normalized +Git remote and otherwise uses the local project root. Consumers never derive it from live Git. +Creation persists it with the project, and normal boot reconciliation fills it for +older records where the field is absent—there is no migration. + +`kind` and `projectKey` are mutable metadata, not identity. Workspace reconciliation watches active project roots and +updates those fields and `updatedAt` when Git facts change, preserving the project's ID, root path, +names, and workspace foreign keys. Attached workspaces are independently refreshed from their own cwd, so an explicit project root never implies a workspace checkout. Empty projects are observed too. @@ -450,7 +456,8 @@ Array of project records. | Field | Type | Description | | ------------- | --------------------------- | -------------------------------------------------------------------------------- | -| `projectId` | `string` | Primary key; new records use opaque `prj_<16 hex>` IDs | +| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs | +| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values | | `rootPath` | `string` | Exact lexically normalized selected root; never realpathed | | `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key | | `displayName` | `string` | Selected-root basename, stable across remote and Git changes | diff --git a/docs/glossary.md b/docs/glossary.md index e2345e704e..bf6aa43d90 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -2,7 +2,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here. -- **Project** — A stable, exact selected-root record. New IDs are opaque `prj_<16 hex>` values; older remote-shaped and path-shaped IDs remain readable compatibility records. Git facts can update mutable kind metadata but never project identity, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label. +- **Project** — A stable, exact selected-root record. Its host-local `projectId` is an opaque `prj_<16 hex>` value. Its persisted `projectKey` is an opaque equivalence key that may group the logical project across hosts. A normalized Git remote is the current key producer, but consumers must not parse or rederive it. Git facts can update mutable kind and grouping metadata but never the ID, root, or default display name. UI: "Project" / "Add project". Forbidden: "Repo", "Repository" as UI label. - **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. Its `id` is opaque workspace identity; its `cwd` is the filesystem directory. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label. - **Archive workspace** — Removes one workspace from active use and archives everything it owns. UI, CLI, and MCP always say "Archive workspace", regardless of backing. The daemon leaves ordinary directories intact and removes a Paseo-owned worktree only when no active workspace still references it. - **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. The git-derived, persisted property of a workspace, used across its lifetime (archive safety, sidebar, grouping). Derived from the cwd's git reality by `deriveWorkspaceKind` in `workspace-registry-model.ts`, not stored from a user choice. Don't confuse with **Isolation** (the create-time intent). @@ -17,7 +17,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads. - **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo. - **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym. -- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label. +- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`. diff --git a/packages/app/e2e/add-project-flow.spec.ts b/packages/app/e2e/add-project-flow.spec.ts index 4705ac090b..51ef13f787 100644 --- a/packages/app/e2e/add-project-flow.spec.ts +++ b/packages/app/e2e/add-project-flow.spec.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { test, expect, type Page } from "./fixtures"; +import { test, expect } from "./fixtures"; import { addProjectFlow, addProjectFlowBack, @@ -13,36 +13,21 @@ import { expectAddProjectPage, expectNewWorkspaceForAddedProject, openAddProjectFlow, - waitForConnectedHost, } from "./helpers/add-project-flow"; import { gotoAppShell } from "./helpers/app"; -import { buildSeededHost } from "./helpers/daemon-registry"; -import { addOfflineHostAndReload } from "./helpers/hosts"; +import { + addConnectedHostAndReload, + addOfflineHostAndReload, + waitForConnectedHost, +} from "./helpers/hosts"; import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; import { expectOpenedProject } from "./helpers/project-picker-ui"; import { connectSeedClient } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; -const EXTRA_HOSTS_KEY = "@paseo:e2e-extra-hosts"; const SECONDARY_HOST_ID = "add-project-flow-secondary"; const SECONDARY_HOST_LABEL = "Secondary Host"; -async function addConnectedHostAndReload(page: Page, host: IsolatedHostDaemon): Promise { - const registryEntry = buildSeededHost({ - serverId: host.serverId, - label: SECONDARY_HOST_LABEL, - endpoint: `127.0.0.1:${host.port}`, - nowIso: new Date().toISOString(), - }); - await page.evaluate( - ({ key, entry }) => { - localStorage.setItem(key, JSON.stringify([entry])); - }, - { key: EXTRA_HOSTS_KEY, entry: registryEntry }, - ); - await page.reload(); -} - async function expectProjectDirectory(pathname: string): Promise { await expect.poll(async () => (await stat(pathname)).isDirectory()).toBe(true); } @@ -149,7 +134,11 @@ test.describe("Add Project command-center flow", () => { test("keyboard selection chooses the second host", async ({ page }) => { await gotoAppShell(page); - await addConnectedHostAndReload(page, secondaryHost); + await addConnectedHostAndReload(page, { + serverId: secondaryHost.serverId, + label: SECONDARY_HOST_LABEL, + port: secondaryHost.port, + }); await waitForConnectedHost(page, { serverId: SECONDARY_HOST_ID, endpoint: `localhost:${secondaryHost.port}`, @@ -167,7 +156,11 @@ test.describe("Add Project command-center flow", () => { page, }) => { await gotoAppShell(page); - await addConnectedHostAndReload(page, secondaryHost); + await addConnectedHostAndReload(page, { + serverId: secondaryHost.serverId, + label: SECONDARY_HOST_LABEL, + port: secondaryHost.port, + }); await waitForConnectedHost(page, { serverId: SECONDARY_HOST_ID, endpoint: `localhost:${secondaryHost.port}`, @@ -211,7 +204,11 @@ test.describe("Add Project command-center flow", () => { try { await gotoAppShell(page); - await addConnectedHostAndReload(page, secondaryHost); + await addConnectedHostAndReload(page, { + serverId: secondaryHost.serverId, + label: SECONDARY_HOST_LABEL, + port: secondaryHost.port, + }); await waitForConnectedHost(page, { serverId: SECONDARY_HOST_ID, endpoint: `localhost:${secondaryHost.port}`, diff --git a/packages/app/e2e/composer-attachments.spec.ts b/packages/app/e2e/composer-attachments.spec.ts index 98d0c7435f..c270cf71b4 100644 --- a/packages/app/e2e/composer-attachments.spec.ts +++ b/packages/app/e2e/composer-attachments.spec.ts @@ -207,7 +207,7 @@ test.describe("Composer attachments", () => { }); await openNewWorkspaceComposer(page, { - projectKey: workspace.projectId, + projectKey: workspace.projectKey, projectDisplayName: workspace.projectDisplayName, }); @@ -293,7 +293,7 @@ test.describe("Composer attachments", () => { }); await openNewWorkspaceComposer(page, { - projectKey: workspace.projectId, + projectKey: workspace.projectKey, projectDisplayName: workspace.projectDisplayName, }); await fillComposerDraft(page, "lock test prompt"); diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index c915d809fa..0fead9d1be 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -32,12 +32,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro await archiveItem.click(); } -async function removeProjectFromSidebar(page: Page, projectId: string): Promise { - const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`); +async function removeProjectFromSidebar(page: Page, projectKey: string): Promise { + const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`); await expect(projectRow).toBeVisible({ timeout: 30_000 }); await projectRow.hover(); - const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`); + const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`); await expect(kebab).toBeVisible({ timeout: 10_000 }); await kebab.click(); @@ -45,7 +45,7 @@ async function removeProjectFromSidebar(page: Page, projectId: string): Promise< // user-confirmed removal proceeds deterministically. page.once("dialog", (dialog) => void dialog.accept()); - const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`); + const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`); await expect(removeItem).toBeVisible({ timeout: 10_000 }); await removeItem.click(); } @@ -152,9 +152,9 @@ test.describe("Project with no workspaces persists", () => { const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" }); try { - const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`); + const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`); const newWorkspaceRow = page.getByTestId( - `sidebar-project-new-workspace-row-${workspace.projectId}`, + `sidebar-project-new-workspace-row-${workspace.projectKey}`, ); const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace"); @@ -195,9 +195,10 @@ test.describe("Project with no workspaces persists", () => { test.describe("Project remove", () => { test("removing a project from project actions removes it from the sidebar", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" }); + let readdedProjectId: string | null = null; try { - const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`); + const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`); await gotoAppShell(page); await waitForSidebarHydration(page); @@ -206,7 +207,7 @@ test.describe("Project remove", () => { timeout: 30_000, }); - await removeProjectFromSidebar(page, workspace.projectId); + await removeProjectFromSidebar(page, workspace.projectKey); await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, { timeout: 30_000, @@ -220,21 +221,24 @@ test.describe("Project remove", () => { const readded = await workspace.client.addProject(workspace.repoPath); expect(readded.error).toBeNull(); expect(readded.project).not.toBeNull(); - const readdedProjectId = readded.project?.projectId ?? ""; + readdedProjectId = readded.project?.projectId ?? ""; + const readdedProjectKey = readded.project?.projectKey ?? ""; expect(readdedProjectId).not.toBe(workspace.projectId); + expect(readdedProjectKey).toBe(workspace.projectKey); expect(readded.project?.projectDisplayName).toBe(workspace.projectDisplayName); await page.reload(); await waitForSidebarHydration(page); - await expect(projectRow).toHaveCount(0, { timeout: 30_000 }); - const readdedProjectRow = page.getByTestId(`sidebar-project-row-${readdedProjectId}`); - await expect(readdedProjectRow).toBeVisible({ timeout: 30_000 }); - await expect(readdedProjectRow).toContainText(workspace.projectDisplayName); - await expect(readdedProjectRow).not.toContainText(workspace.repoPath); + await expect(projectRow).toBeVisible({ timeout: 30_000 }); + await expect(projectRow).toContainText(workspace.projectDisplayName); + await expect(projectRow).not.toContainText(workspace.repoPath); await expect( - page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectId}`), + page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`), ).toBeVisible({ timeout: 30_000 }); } finally { + if (readdedProjectId) { + await workspace.client.removeProject(readdedProjectId).catch(() => undefined); + } await workspace.cleanup(); } }); diff --git a/packages/app/e2e/helpers/add-project-flow.ts b/packages/app/e2e/helpers/add-project-flow.ts index 92cf3ff883..18e7a0da84 100644 --- a/packages/app/e2e/helpers/add-project-flow.ts +++ b/packages/app/e2e/helpers/add-project-flow.ts @@ -37,17 +37,6 @@ export function addProjectFlowMethod(page: Page, method: AddProjectMethod): Loca return page.getByTestId(`add-project-flow-method-${method}`); } -export async function waitForConnectedHost( - page: Page, - input: { serverId: string; endpoint: string }, -): Promise { - await page.getByTestId("sidebar-hosts-trigger").click(); - const host = page.getByTestId(`sidebar-host-row-${input.serverId}`); - await expect(host).toContainText(input.endpoint, { timeout: 30_000 }); - await page.keyboard.press("Escape"); - await expect(host).not.toBeVisible(); -} - export async function expectAddProjectPage(page: Page, kind: AddProjectFlowPage): Promise { const currentPage = page.getByTestId(`add-project-flow-page-${kind}`); await expect(currentPage).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/e2e/helpers/hosts.ts b/packages/app/e2e/helpers/hosts.ts index 117981d0a7..0c003fed7f 100644 --- a/packages/app/e2e/helpers/hosts.ts +++ b/packages/app/e2e/helpers/hosts.ts @@ -57,6 +57,69 @@ export async function addOfflineHostAndReload( await page.reload(); } +export async function addConnectedHostAndReload( + page: Page, + input: { serverId: string; label: string; port: number; primaryLabel?: string }, +): Promise { + await addConnectedHostsAndReload(page, [input], { primaryLabel: input.primaryLabel }); +} + +export async function addConnectedHostsAndReload( + page: Page, + inputs: Array<{ serverId: string; label: string; port: number }>, + options?: { primaryLabel?: string }, +): Promise { + const connectedHosts = inputs.map((input) => + buildSeededHost({ + serverId: input.serverId, + label: input.label, + endpoint: `127.0.0.1:${input.port}`, + nowIso: new Date().toISOString(), + }), + ); + + await page.evaluate( + ({ hosts, keys, primaryLabel }) => { + const nonce = localStorage.getItem(keys.nonce); + if (!nonce) { + throw new Error("Expected the e2e seed nonce before overriding the host registry."); + } + const raw = localStorage.getItem(keys.registry); + const registry: Array<{ serverId: string; label?: string }> = raw ? JSON.parse(raw) : []; + if (primaryLabel && registry[0]) registry[0].label = primaryLabel; + for (const host of hosts) { + if (!registry.some((entry) => entry.serverId === host.serverId)) { + registry.push(host); + } + } + localStorage.setItem(keys.registry, JSON.stringify(registry)); + localStorage.setItem(keys.disableSeedOnce, nonce); + }, + { + hosts: connectedHosts, + keys: { + registry: REGISTRY_KEY, + nonce: SEED_NONCE_KEY, + disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY, + }, + primaryLabel: options?.primaryLabel, + }, + ); + + await page.reload(); +} + +export async function waitForConnectedHost( + page: Page, + input: { serverId: string; endpoint: string }, +): Promise { + await page.getByTestId("sidebar-hosts-trigger").click(); + const host = page.getByTestId(`sidebar-host-row-${input.serverId}`); + await expect(host).toContainText(input.endpoint, { timeout: 30_000 }); + await page.keyboard.press("Escape"); + await expect(host).not.toBeVisible(); +} + export async function openSidebarDisplayPreferences(page: Page): Promise { await page.getByTestId("sidebar-display-preferences-menu").click(); await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({ diff --git a/packages/app/e2e/helpers/isolated-host-daemon.ts b/packages/app/e2e/helpers/isolated-host-daemon.ts index 1d2d6a0db1..80b203169a 100644 --- a/packages/app/e2e/helpers/isolated-host-daemon.ts +++ b/packages/app/e2e/helpers/isolated-host-daemon.ts @@ -9,6 +9,7 @@ import { withDisabledE2ESpeechEnv } from "./speech-env"; export interface IsolatedHostDaemon { serverId: string; port: number; + paseoHome: string; restart(): Promise; close(): Promise; } @@ -133,6 +134,7 @@ export async function startIsolatedHostDaemon(serverId: string): Promise { if (closed) throw new Error(`Cannot restart closed isolated daemon ${serverId}`); await stopProcess(child); diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 20bbd2c81b..f9245a17c6 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -18,6 +18,7 @@ type NewWorkspaceDaemonClient = Pick< | "getPaseoWorktreeList" | "getDaemonConfig" | "inspectWorkspaceRecovery" + | "listProjects" | "on" | "patchDaemonConfig" | "removeProject" @@ -29,6 +30,7 @@ type WorkspaceDescriptor = NonNullable; export interface OpenedProject { workspaceId: string; + projectId: string; projectKey: string; projectDisplayName: string; workspaceName: string; @@ -45,10 +47,19 @@ function requireWorkspace(payload: WorkspacePayload) { return payload.workspace; } -function openedProjectFromWorkspace(workspace: WorkspaceDescriptor): OpenedProject { +async function openedProjectFromWorkspace( + client: NewWorkspaceDaemonClient, + workspace: WorkspaceDescriptor, +): Promise { + const payload = await client.listProjects(); + const project = payload.projects.find((candidate) => candidate.projectId === workspace.projectId); + if (!project?.projectKey) { + throw new Error(`Project ${workspace.projectId} has no project key`); + } return { workspaceId: workspace.id, - projectKey: workspace.projectId, + projectId: workspace.projectId, + projectKey: project.projectKey, projectDisplayName: workspace.projectDisplayName, workspaceName: workspace.name, workspaceDirectory: workspace.workspaceDirectory, @@ -107,7 +118,7 @@ export async function openProjectViaDaemon( source: { kind: "directory", path: repoPath }, }), ); - return openedProjectFromWorkspace(workspace); + return openedProjectFromWorkspace(client, workspace); } export async function archiveWorkspaceFromDaemon( @@ -149,7 +160,7 @@ export async function createWorktreeViaDaemon( worktreeSlug: input.slug, }); const workspace = requireWorkspace(payload); - return openedProjectFromWorkspace(workspace); + return openedProjectFromWorkspace(client, workspace); } export async function openNewWorkspaceComposer( diff --git a/packages/app/e2e/helpers/project-grouping.ts b/packages/app/e2e/helpers/project-grouping.ts new file mode 100644 index 0000000000..61e995ff16 --- /dev/null +++ b/packages/app/e2e/helpers/project-grouping.ts @@ -0,0 +1,131 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import { gotoAppShell } from "./app"; +import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts"; +import { openProjectSettings } from "./project-settings"; +import { waitForSidebarHydration } from "./workspace-ui"; + +const PROJECT_VISIBILITY_TIMEOUT = 30_000; + +function projectGroup(page: Page, projectName: string): Locator { + return page.getByRole("group", { name: projectName, exact: true }); +} + +function projectGroupWithWorkspace( + page: Page, + projectName: string, + workspaceName: string, +): Locator { + return projectGroup(page, projectName).filter({ hasText: workspaceName }); +} + +export async function openProjectDirectory(page: Page): Promise { + await gotoAppShell(page); + await waitForSidebarHydration(page); +} + +export async function openProjectDirectoryWithHosts( + page: Page, + input: { + hosts: Array<{ serverId: string; label: string; port: number }>; + primaryLabel?: string; + }, +): Promise { + await gotoAppShell(page); + await addConnectedHostsAndReload(page, input.hosts, { primaryLabel: input.primaryLabel }); + for (const host of input.hosts) { + await waitForConnectedHost(page, { + serverId: host.serverId, + endpoint: `localhost:${host.port}`, + }); + } + await waitForSidebarHydration(page); +} + +export async function expectProjectContainsWorkspaces( + page: Page, + input: { projectName: string; workspaceNames: string[] }, +): Promise { + const group = projectGroup(page, input.projectName); + await expect(group).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT }); + for (const workspaceName of input.workspaceNames) { + await expect(group.getByRole("button").filter({ hasText: workspaceName })).toBeVisible({ + timeout: PROJECT_VISIBILITY_TIMEOUT, + }); + } +} + +export async function expectSeparateProjects( + page: Page, + projects: Array<{ projectName: string; workspaceName: string }>, +): Promise { + for (const project of projects) { + await expect( + projectGroupWithWorkspace(page, project.projectName, project.workspaceName), + ).toHaveCount(1, { timeout: PROJECT_VISIBILITY_TIMEOUT }); + } +} + +export async function beginWorkspaceFromProject(page: Page, projectName: string): Promise { + const group = projectGroup(page, projectName); + await expect(group).toBeVisible({ timeout: PROJECT_VISIBILITY_TIMEOUT }); + await group.hover(); + await group.getByLabel(`Create a new workspace for ${projectName}`).click(); + await expect(page.getByRole("button", { name: "Workspace project", exact: true })).toContainText( + projectName, + { timeout: PROJECT_VISIBILITY_TIMEOUT }, + ); +} + +export async function selectWorkspaceHost(page: Page, hostName: string): Promise { + await page.getByRole("button", { name: "Host", exact: true }).click(); + await page.getByRole("button", { name: hostName, exact: true }).click(); + await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName); +} + +export async function createWorkspaceWithoutAgent(page: Page): Promise { + await page.getByRole("button", { name: "Create", exact: true }).click(); +} + +export async function expectProjectWorkspaceCountForHost( + page: Page, + input: { projectName: string; hostName: string; count: number }, +): Promise { + const group = projectGroup(page, input.projectName); + await expect(group.getByRole("button").filter({ hasText: input.hostName })).toHaveCount( + input.count, + { timeout: PROJECT_VISIBILITY_TIMEOUT }, + ); +} + +export async function renameProject(page: Page, name: string): Promise { + await page.getByRole("button", { name: "Rename project", exact: true }).click(); + await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name); + await page.getByRole("button", { name: "Save project name", exact: true }).click(); + await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({ + timeout: PROJECT_VISIBILITY_TIMEOUT, + }); +} + +export async function openGroupedProjectSettings(page: Page, projectName: string): Promise { + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(page).toHaveURL(/\/settings\/general$/); + await page.getByRole("button", { name: "Projects", exact: true }).click(); + await expect(page).toHaveURL(/\/settings\/projects$/); + await openProjectSettings(page, projectName); +} + +export async function switchProjectSettingsHost(page: Page, hostName: string): Promise { + const projectSettings = page.getByRole("main"); + await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click(); + await page.getByRole("button", { name: hostName, exact: true }).click(); + await expect( + projectSettings.getByRole("button", { name: "Switch host", exact: true }), + ).toContainText(hostName); +} + +export async function expectProjectSettingsName(page: Page, projectName: string): Promise { + await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText( + projectName, + { timeout: PROJECT_VISIBILITY_TIMEOUT }, + ); +} diff --git a/packages/app/e2e/helpers/project-picker-ui.ts b/packages/app/e2e/helpers/project-picker-ui.ts index 019d482e86..9fef26f719 100644 --- a/packages/app/e2e/helpers/project-picker-ui.ts +++ b/packages/app/e2e/helpers/project-picker-ui.ts @@ -7,7 +7,8 @@ export async function expectOpenedProject(page: Page, projectName: string): Prom .first(); await expect(projectRow).toBeVisible({ timeout: 30_000 }); - const testId = await projectRow.getAttribute("data-testid"); - expect(testId).not.toBeNull(); - return testId!.replace("sidebar-project-row-", ""); + await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 }); + const projectId = new URL(page.url()).searchParams.get("projectId"); + expect(projectId).not.toBeNull(); + return projectId!; } diff --git a/packages/app/e2e/helpers/schedule-fake-host.ts b/packages/app/e2e/helpers/schedule-fake-host.ts index 49746b495d..df54741fc0 100644 --- a/packages/app/e2e/helpers/schedule-fake-host.ts +++ b/packages/app/e2e/helpers/schedule-fake-host.ts @@ -16,6 +16,7 @@ type SessionRequest = Record & { type?: string; requestId?: str export interface FakeScheduleHostWorkspace { serverId: string; projectId: string; + projectKey: string; projectDisplayName: string; workspace: Record; } @@ -120,6 +121,7 @@ export async function buildFakeScheduleHostWorkspace( return { serverId: "schedule-fake-host", projectId, + projectKey: workspace.projectKey, projectDisplayName: FAKE_HOST_PROJECT_DISPLAY_NAME, workspace: { ...baseWorkspace, @@ -139,6 +141,7 @@ export async function installFakeScheduleHost(input: { port: string; serverId: string; workspace: Record; + project: Pick; schedules?: FakeScheduleSummary[]; }): Promise { await input.page.routeWebSocket(wsRoutePatternForPort(input.port), (ws) => { @@ -154,6 +157,7 @@ export async function installFakeScheduleHost(input: { features: { providersSnapshot: true, workspaceMultiplicity: true, + projectList: true, projectAdd: true, projectRemove: true, workspaceRecovery: true, @@ -197,6 +201,22 @@ export async function installFakeScheduleHost(input: { }), ); return; + case "project.list.request": + ws.send( + buildSessionMessage("project.list.response", { + requestId, + projects: [ + { + projectId: input.project.projectId, + projectKey: input.project.projectKey, + projectDisplayName: input.project.projectDisplayName, + projectRootPath: String(input.workspace.projectRootPath), + projectKind: "git", + }, + ], + }), + ); + return; case "fetch_agents_request": ws.send( buildSessionMessage("fetch_agents_response", { diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index d3fd797381..bcc7b41447 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -13,6 +13,11 @@ export interface SeedWorkspaceDescriptor { workspaceDirectory: string; } +interface SeedProjectDescriptor { + projectId: string; + projectKey?: string; +} + /** * The general-purpose E2E daemon client used to seed and drive state out of * band (workspaces, agents, terminals) while the UI is exercised through the @@ -25,15 +30,18 @@ export interface SeedDaemonClient { addProject(cwd: string): Promise<{ project: { projectId: string; + projectKey?: string; projectDisplayName: string; projectRootPath: string; } | null; error: string | null; }>; removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>; + renameProject(projectId: string, customName: string | null): Promise; fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{ entries: SeedWorkspaceDescriptor[]; }>; + listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>; createWorkspace(input: { source: | { kind: "directory"; path: string; projectId?: string } @@ -179,8 +187,10 @@ export interface SeededWorkspace { workspaceId: string; workspaceName: string; workspaceDirectory: string; - /** Stable project identity the daemon groups workspaces under. */ + /** Host-local identity used by daemon project operations. */ projectId: string; + /** Opaque cross-host key used by grouped project UI. */ + projectKey: string; /** Project label the UI shows (owner/repo for known remotes, else basename). */ projectDisplayName: string; cleanup(): Promise; @@ -208,6 +218,11 @@ export async function seedWorkspace(options: { throw new Error(created.error ?? `Failed to create workspace ${project.path}`); } const workspace = created.workspace; + const listed = await client.listProjects(); + const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId); + if (!descriptor?.projectKey) { + throw new Error(`Created project ${workspace.projectId} has no project key`); + } return { client, repoPath: project.path, @@ -215,6 +230,7 @@ export async function seedWorkspace(options: { workspaceName: workspace.name, workspaceDirectory: workspace.workspaceDirectory, projectId: workspace.projectId, + projectKey: descriptor.projectKey, projectDisplayName: workspace.projectDisplayName, cleanup: async () => { await client.removeProject(workspace.projectId).catch(() => undefined); diff --git a/packages/app/e2e/import-session.opencode.real.spec.ts b/packages/app/e2e/import-session.opencode.real.spec.ts index 9e1e909f55..2f5660f202 100644 --- a/packages/app/e2e/import-session.opencode.real.spec.ts +++ b/packages/app/e2e/import-session.opencode.real.spec.ts @@ -64,6 +64,13 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise project.projectId === createdWorkspace.workspace?.projectId, + ); + const projectKey = descriptor?.projectKey; + if (!projectKey) { + throw new Error(`Created workspace ${createdWorkspace.workspace.id} has no project key`); + } return { prompt, promptPreview, @@ -75,6 +82,7 @@ async function seedPaseoWorkspaceWithOpenCodeSession(): Promise { await client.close().catch(() => undefined); diff --git a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts index b106810b84..9b4fbf6e15 100644 --- a/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/new-workspace-codex-mode-preferences.spec.ts @@ -165,7 +165,7 @@ test.describe("New workspace Codex mode preferences", () => { await waitForSidebarHydration(page); await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }); @@ -221,7 +221,7 @@ test.describe("New workspace Codex mode preferences", () => { await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }); diff --git a/packages/app/e2e/new-workspace-composer-draft.spec.ts b/packages/app/e2e/new-workspace-composer-draft.spec.ts index 3606fe7e08..d561f846b7 100644 --- a/packages/app/e2e/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/new-workspace-composer-draft.spec.ts @@ -34,7 +34,7 @@ test.describe("New workspace composer draft", () => { await gotoAppShell(page); await waitForSidebarHydration(page); await openNewWorkspaceComposer(page, { - projectKey: firstProject.projectId, + projectKey: firstProject.projectKey, projectDisplayName: firstProject.projectDisplayName, }); await expectNewWorkspaceProjectSelected(page, firstProject.projectDisplayName); @@ -42,7 +42,7 @@ test.describe("New workspace composer draft", () => { await fillNewWorkspaceDraft(page, DRAFT); await selectNewWorkspaceProject(page, { - projectKey: secondProject.projectId, + projectKey: secondProject.projectKey, projectDisplayName: secondProject.projectDisplayName, }); diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index b2ad1c8840..78aad7ecec 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -179,13 +179,13 @@ test.describe("New workspace entry points", () => { try { await gotoAppShell(page); await waitForSidebarHydration(page); - await expect(projectRow(page, projectA.projectId)).toBeVisible({ timeout: 30_000 }); - await expect(projectRow(page, projectB.projectId)).toBeVisible({ timeout: 30_000 }); - await expect(projectRow(page, projectC.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, projectA.projectKey)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, projectB.projectKey)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, projectC.projectKey)).toBeVisible({ timeout: 30_000 }); // Project A's row icon opens New Workspace with A preselected. await openNewWorkspaceComposer(page, { - projectKey: projectA.projectId, + projectKey: projectA.projectKey, projectDisplayName: projectA.projectDisplayName, }); await expectNewWorkspaceProjectSelected(page, projectA.projectDisplayName); @@ -194,7 +194,9 @@ test.describe("New workspace entry points", () => { // manualProjectKey is what the reused 'new' screen must reset when the next // route-driven navigation targets a different project. await page.getByTestId("new-workspace-project-picker-trigger").click(); - const optionC = page.getByTestId(`new-workspace-project-picker-option-${projectC.projectId}`); + const optionC = page.getByTestId( + `new-workspace-project-picker-option-${projectC.projectKey}`, + ); await expect(optionC).toBeVisible({ timeout: 30_000 }); await optionC.click(); await expectNewWorkspaceProjectSelected(page, projectC.projectDisplayName); @@ -203,7 +205,7 @@ test.describe("New workspace entry points", () => { // because the stale manual choice (C) was reset on the route change. If the // reset were missing, the trigger would still read C. await openNewWorkspaceComposer(page, { - projectKey: projectB.projectId, + projectKey: projectB.projectKey, projectDisplayName: projectB.projectDisplayName, }); await expectNewWorkspaceProjectSelected(page, projectB.projectDisplayName); @@ -226,8 +228,8 @@ test.describe("New workspace entry points", () => { try { await gotoAppShell(page); await waitForSidebarHydration(page); - await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 }); - await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 }); // Open New Workspace for the non-git project via the global button, then // select it in the picker (the per-row icon would preselect it too). @@ -236,7 +238,7 @@ test.describe("New workspace entry points", () => { await expect(trigger).toBeVisible({ timeout: 30_000 }); await trigger.click(); const nonGitOption = page.getByTestId( - `new-workspace-project-picker-option-${nonGitProject.projectId}`, + `new-workspace-project-picker-option-${nonGitProject.projectKey}`, ); await expect(nonGitOption).toBeVisible({ timeout: 30_000 }); await nonGitOption.click(); @@ -249,7 +251,7 @@ test.describe("New workspace entry points", () => { // Switching to the git project on the same screen reveals the Isolation row. await trigger.click(); const gitOption = page.getByTestId( - `new-workspace-project-picker-option-${gitProject.projectId}`, + `new-workspace-project-picker-option-${gitProject.projectKey}`, ); await expect(gitOption).toBeVisible({ timeout: 30_000 }); await gitOption.click(); diff --git a/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts b/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts index 9a89486696..9f125f19e2 100644 --- a/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts +++ b/packages/app/e2e/new-workspace-mode-cycle-safety.spec.ts @@ -126,7 +126,7 @@ test.describe("New Workspace mode cycle safety", () => { // so its handler is still registered when we cycle here. await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }); diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index cd5cd86082..2fd03012cb 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -195,6 +195,7 @@ async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test"). test.describe("New workspace flow", () => { let client: Awaited>; const localWorkspaceIds = new Set(); + const localProjectIds = new Set(); const createdWorktreeDirectories = new Set(); const localGithubFixtures = new Set(); @@ -212,9 +213,13 @@ test.describe("New workspace flow", () => { for (const workspaceId of localWorkspaceIds) { await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined); } + for (const projectId of localProjectIds) { + await client.removeProject(projectId).catch(() => undefined); + } } createdWorktreeDirectories.clear(); localWorkspaceIds.clear(); + localProjectIds.clear(); await client?.close().catch(() => undefined); }); @@ -830,6 +835,7 @@ test.describe("New workspace flow", () => { const openedProject = await openProjectViaDaemon(client, mainCheckout.path); localWorkspaceIds.add(openedProject.workspaceId); + localProjectIds.add(openedProject.projectId); await gotoAppShell(page); await waitForSidebarHydration(page); @@ -881,6 +887,7 @@ test.describe("New workspace flow", () => { const openedProject = await openProjectViaDaemon(client, mainCheckout.path); localWorkspaceIds.add(openedProject.workspaceId); + localProjectIds.add(openedProject.projectId); await gotoAppShell(page); await waitForSidebarHydration(page); diff --git a/packages/app/e2e/project-picker-desktop.spec.ts b/packages/app/e2e/project-picker-desktop.spec.ts index a4a71f0567..878c42ee3b 100644 --- a/packages/app/e2e/project-picker-desktop.spec.ts +++ b/packages/app/e2e/project-picker-desktop.spec.ts @@ -1,8 +1,10 @@ import { test, expect } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { injectDesktopBridge, waitForDirectoryDialog } from "./helpers/desktop-updates"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { waitForConnectedHost } from "./helpers/hosts"; import { expectOpenedProject } from "./helpers/project-picker-ui"; -import { expectNewWorkspaceForAddedProject } from "./helpers/add-project-flow"; +import { expectNewWorkspaceForAddedProject, openAddProjectFlow } from "./helpers/add-project-flow"; import { getServerId } from "./helpers/server-id"; import { connectSeedClient } from "./helpers/seed-client"; @@ -18,8 +20,12 @@ test("Browse opens the folder selected by the desktop dialog", async ({ dialogOpenResult: projectPickerFixture.projectPath, }); await gotoAppShell(page); + await waitForConnectedHost(page, { + serverId: getServerId(), + endpoint: `localhost:${getE2EDaemonPort()}`, + }); - await page.getByTestId("sidebar-add-project").click(); + await openAddProjectFlow(page); const browse = page.getByRole("button", { name: /^Browse/ }); await expect(browse).toBeVisible({ timeout: 30_000 }); await browse.click(); @@ -50,8 +56,12 @@ test("canceling Browse returns to the Add Project methods", async ({ dialogOpenResult: null, }); await gotoAppShell(page); + await waitForConnectedHost(page, { + serverId: getServerId(), + endpoint: `localhost:${getE2EDaemonPort()}`, + }); - await page.getByTestId("sidebar-add-project").click(); + await openAddProjectFlow(page); const browse = page.getByRole("button", { name: /^Browse/ }); await expect(browse).toBeVisible({ timeout: 30_000 }); await browse.click(); diff --git a/packages/app/e2e/schedules-edit-model-hydration.spec.ts b/packages/app/e2e/schedules-edit-model-hydration.spec.ts index 0a706b0e28..4b7851ccb0 100644 --- a/packages/app/e2e/schedules-edit-model-hydration.spec.ts +++ b/packages/app/e2e/schedules-edit-model-hydration.spec.ts @@ -163,6 +163,7 @@ test.describe("Schedules", () => { port: fakePort, serverId: fakeHost.serverId, workspace: fakeHost.workspace, + project: fakeHost, schedules: [ buildFakeHostSchedule({ id: scheduleId, diff --git a/packages/app/e2e/schedules-project-target.spec.ts b/packages/app/e2e/schedules-project-target.spec.ts index 836dbbb24e..64c02ee304 100644 --- a/packages/app/e2e/schedules-project-target.spec.ts +++ b/packages/app/e2e/schedules-project-target.spec.ts @@ -205,7 +205,7 @@ test.describe("Schedules project target", () => { await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0); await page.getByRole("button", { name: /select project/i }).click(); - await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click(); + await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click(); const projectTrigger = page.getByTestId("schedule-project-trigger"); await expect(projectTrigger).toContainText(workspace.projectDisplayName); await expectSettled(projectTrigger); @@ -245,6 +245,7 @@ test.describe("Schedules project target", () => { port: fakePort, serverId: fakeHost.serverId, workspace: fakeHost.workspace, + project: fakeHost, }); await addFakeScheduleHostAndReload({ page, @@ -277,6 +278,7 @@ test.describe("Schedules project target", () => { port: fakePort, serverId: fakeHost.serverId, workspace: fakeHost.workspace, + project: fakeHost, }); await gotoAppShell(page); @@ -313,7 +315,7 @@ test.describe("Schedules project target", () => { await expectSettled(hostTrigger); await projectTrigger.click(); - await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click(); + await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click(); await expect(projectTrigger).toContainText(workspace.projectDisplayName); await expectSettled(projectTrigger); @@ -335,8 +337,7 @@ test.describe("Schedules project target", () => { await expectSettled(projectTrigger); await projectTrigger.click(); - await expect(page.getByTestId(`schedule-project-option-${workspace.projectId}`)).toHaveCount(0); - await page.getByTestId(`schedule-project-option-${fakeHost.projectId}`).click(); + await page.getByTestId(`schedule-project-option-${fakeHost.projectKey}`).click(); await expect(projectTrigger).toContainText(fakeHost.projectDisplayName); await expectSettled(projectTrigger); await expect(modelTrigger).toContainText(/select model/i); @@ -362,7 +363,7 @@ test.describe("Schedules project target", () => { await page.getByTestId("schedules-empty-new").click(); await page.getByRole("button", { name: /select project/i }).click(); - await page.getByTestId(`schedule-project-option-${workspace.projectId}`).click(); + await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click(); await selectModelByLabel(page, "Ten second stream"); await expect( page.getByText("Off keeps each run's workspace in the sidebar for inspection."), diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts index 30ebb767b6..fe57e52481 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -53,8 +53,8 @@ test.describe("Model B sidebar shape", () => { // Both projects are expandable parents — the non-git one is NOT flattened // into a bare workspace link. - await expect(projectRow(page, gitProject.projectId)).toBeVisible({ timeout: 30_000 }); - await expect(projectRow(page, nonGitProject.projectId)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000 }); + await expect(projectRow(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000 }); // Each parent shows both of its workspace rows underneath. await expect(workspaceRow(page, gitProject.workspaceId)).toBeVisible({ timeout: 30_000 }); @@ -65,12 +65,12 @@ test.describe("Model B sidebar shape", () => { // Both projects show a per-row New workspace icon (revealed on hover): the // git project can branch off a worktree, and the non-git project can add // another workspace because the host supports workspaceMultiplicity. - await projectRow(page, gitProject.projectId).hover(); - await expect(projectNewWorktreeIcon(page, gitProject.projectId)).toBeVisible({ + await projectRow(page, gitProject.projectKey).hover(); + await expect(projectNewWorktreeIcon(page, gitProject.projectKey)).toBeVisible({ timeout: 30_000, }); - await projectRow(page, nonGitProject.projectId).hover(); - await expect(projectNewWorktreeIcon(page, nonGitProject.projectId)).toBeVisible({ + await projectRow(page, nonGitProject.projectKey).hover(); + await expect(projectNewWorktreeIcon(page, nonGitProject.projectKey)).toBeVisible({ timeout: 30_000, }); diff --git a/packages/app/e2e/sidebar-project-grouping.spec.ts b/packages/app/e2e/sidebar-project-grouping.spec.ts new file mode 100644 index 0000000000..7771862947 --- /dev/null +++ b/packages/app/e2e/sidebar-project-grouping.spec.ts @@ -0,0 +1,404 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { test as base } from "./fixtures"; +import { + beginWorkspaceFromProject, + createWorkspaceWithoutAgent, + expectProjectContainsWorkspaces, + expectProjectSettingsName, + expectProjectWorkspaceCountForHost, + expectSeparateProjects, + openGroupedProjectSettings, + openProjectDirectory, + openProjectDirectoryWithHosts, + renameProject, + selectWorkspaceHost, + switchProjectSettingsHost, +} from "./helpers/project-grouping"; +import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; +import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { createTempGitRepo } from "./helpers/workspace"; + +const PRIMARY_HOST_LABEL = "Primary Host"; +const SECONDARY_HOST_LABEL = "Secondary Host"; +const LEGACY_PRIMARY_HOST_LABEL = "Legacy Primary Host"; +const LEGACY_SECONDARY_HOST_LABEL = "Legacy Secondary Host"; +const GROUPED_PROJECT_NAME = "paseo-e2e/grouped-project"; +const SHARED_REMOTE_URL = "https://github.com/paseo-e2e/grouped-project.git"; +const SUBDIRECTORY = path.join("packages", "app"); +const REPO_FILES = [{ path: path.join(SUBDIRECTORY, "package.json"), content: "{}\n" }]; + +interface HostConnection { + serverId: string; + label: string; + port: number; +} + +interface ProjectDirectoryScenario { + hosts: HostConnection[]; + primaryLabel?: string; +} + +interface CreatedProject { + projectId: string; +} + +async function createProject( + client: SeedDaemonClient, + input: { + projectPath: string; + serverId: string; + workspaceName: string; + projectName?: string; + }, +): Promise { + const created = await client.createWorkspace({ + source: { kind: "directory", path: input.projectPath }, + title: input.workspaceName, + }); + if (!created.workspace) { + throw new Error(created.error ?? `Failed to create project on ${input.serverId}`); + } + if (input.projectName) { + await client.renameProject(created.workspace.projectId, input.projectName); + } + return { projectId: created.workspace.projectId }; +} + +async function removePersistedProjectKeys(host: IsolatedHostDaemon): Promise { + const projectsPath = path.join(host.paseoHome, "projects", "projects.json"); + const projects = JSON.parse(await readFile(projectsPath, "utf8")) as Array< + Record + >; + for (const project of projects) delete project.projectKey; + await writeFile(projectsPath, JSON.stringify(projects)); +} + +const test = base.extend<{ + crossHostProject: ProjectDirectoryScenario; + reconciledCrossHostProject: ProjectDirectoryScenario; + rootAndSubdirectoryProjects: ProjectDirectoryScenario; + crossHostSubdirectoryProject: ProjectDirectoryScenario; + sameHostClones: ProjectDirectoryScenario; +}>({ + crossHostProject: async ({ page: _page }, provide) => { + const secondaryHost = await startIsolatedHostDaemon("project-grouping-secondary"); + const primaryRepo = await createTempGitRepo("grouped-primary-", { + originUrl: SHARED_REMOTE_URL, + }); + const secondaryRepo = await createTempGitRepo("grouped-secondary-", { + originUrl: SHARED_REMOTE_URL, + }); + const primaryClient = await connectSeedClient(); + const secondaryClient = await connectSeedClient({ port: secondaryHost.port }); + let primary: CreatedProject | null = null; + let secondary: CreatedProject | null = null; + + try { + primary = await createProject(primaryClient, { + projectPath: primaryRepo.path, + serverId: getServerId(), + workspaceName: "Primary workspace", + projectName: GROUPED_PROJECT_NAME, + }); + secondary = await createProject(secondaryClient, { + projectPath: secondaryRepo.path, + serverId: secondaryHost.serverId, + workspaceName: "Secondary workspace", + projectName: GROUPED_PROJECT_NAME, + }); + await provide({ + primaryLabel: PRIMARY_HOST_LABEL, + hosts: [ + { + serverId: secondaryHost.serverId, + label: SECONDARY_HOST_LABEL, + port: secondaryHost.port, + }, + ], + }); + } finally { + if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined); + if (secondary) + await secondaryClient.removeProject(secondary.projectId).catch(() => undefined); + await primaryClient.close().catch(() => undefined); + await secondaryClient.close().catch(() => undefined); + await primaryRepo.cleanup().catch(() => undefined); + await secondaryRepo.cleanup().catch(() => undefined); + await secondaryHost.close().catch(() => undefined); + } + }, + + reconciledCrossHostProject: async ({ page: _page }, provide) => { + const primaryHost = await startIsolatedHostDaemon("project-grouping-legacy-primary"); + const secondaryHost = await startIsolatedHostDaemon("project-grouping-legacy-secondary"); + const primaryRepo = await createTempGitRepo("grouped-legacy-primary-", { + originUrl: SHARED_REMOTE_URL, + }); + const secondaryRepo = await createTempGitRepo("grouped-legacy-secondary-", { + originUrl: SHARED_REMOTE_URL, + }); + const primaryClient = await connectSeedClient({ port: primaryHost.port }); + const secondaryClient = await connectSeedClient({ port: secondaryHost.port }); + + try { + await createProject(primaryClient, { + projectPath: primaryRepo.path, + serverId: primaryHost.serverId, + workspaceName: "Recovered primary workspace", + projectName: GROUPED_PROJECT_NAME, + }); + await createProject(secondaryClient, { + projectPath: secondaryRepo.path, + serverId: secondaryHost.serverId, + workspaceName: "Recovered secondary workspace", + projectName: GROUPED_PROJECT_NAME, + }); + await primaryClient.close(); + await secondaryClient.close(); + await removePersistedProjectKeys(primaryHost); + await removePersistedProjectKeys(secondaryHost); + await Promise.all([primaryHost.restart(), secondaryHost.restart()]); + + await provide({ + hosts: [ + { + serverId: primaryHost.serverId, + label: LEGACY_PRIMARY_HOST_LABEL, + port: primaryHost.port, + }, + { + serverId: secondaryHost.serverId, + label: LEGACY_SECONDARY_HOST_LABEL, + port: secondaryHost.port, + }, + ], + }); + } finally { + await primaryClient.close().catch(() => undefined); + await secondaryClient.close().catch(() => undefined); + await primaryHost.close().catch(() => undefined); + await secondaryHost.close().catch(() => undefined); + await primaryRepo.cleanup().catch(() => undefined); + await secondaryRepo.cleanup().catch(() => undefined); + } + }, + + rootAndSubdirectoryProjects: async ({ page: _page }, provide) => { + const repo = await createTempGitRepo("grouped-root-subdir-", { + originUrl: SHARED_REMOTE_URL, + files: REPO_FILES, + }); + const client = await connectSeedClient(); + const projects: CreatedProject[] = []; + + try { + projects.push( + await createProject(client, { + projectPath: repo.path, + serverId: getServerId(), + workspaceName: "Repository root workspace", + projectName: "Repository root project", + }), + ); + projects.push( + await createProject(client, { + projectPath: path.join(repo.path, SUBDIRECTORY), + serverId: getServerId(), + workspaceName: "App package workspace", + projectName: "App package project", + }), + ); + await provide({ hosts: [] }); + } finally { + for (const project of projects) { + await client.removeProject(project.projectId).catch(() => undefined); + } + await client.close().catch(() => undefined); + await repo.cleanup().catch(() => undefined); + } + }, + + crossHostSubdirectoryProject: async ({ page: _page }, provide) => { + const secondaryHost = await startIsolatedHostDaemon("project-grouping-subdir-secondary"); + const primaryRepo = await createTempGitRepo("grouped-subdir-primary-", { + originUrl: SHARED_REMOTE_URL, + files: REPO_FILES, + }); + const secondaryRepo = await createTempGitRepo("grouped-subdir-secondary-", { + originUrl: SHARED_REMOTE_URL, + files: REPO_FILES, + }); + const primaryClient = await connectSeedClient(); + const secondaryClient = await connectSeedClient({ port: secondaryHost.port }); + let primary: CreatedProject | null = null; + let secondary: CreatedProject | null = null; + + try { + primary = await createProject(primaryClient, { + projectPath: path.join(primaryRepo.path, SUBDIRECTORY), + serverId: getServerId(), + workspaceName: "Primary app workspace", + projectName: GROUPED_PROJECT_NAME, + }); + secondary = await createProject(secondaryClient, { + projectPath: path.join(secondaryRepo.path, SUBDIRECTORY), + serverId: secondaryHost.serverId, + workspaceName: "Secondary app workspace", + projectName: GROUPED_PROJECT_NAME, + }); + await provide({ + primaryLabel: PRIMARY_HOST_LABEL, + hosts: [ + { + serverId: secondaryHost.serverId, + label: SECONDARY_HOST_LABEL, + port: secondaryHost.port, + }, + ], + }); + } finally { + if (primary) await primaryClient.removeProject(primary.projectId).catch(() => undefined); + if (secondary) + await secondaryClient.removeProject(secondary.projectId).catch(() => undefined); + await primaryClient.close().catch(() => undefined); + await secondaryClient.close().catch(() => undefined); + await primaryRepo.cleanup().catch(() => undefined); + await secondaryRepo.cleanup().catch(() => undefined); + await secondaryHost.close().catch(() => undefined); + } + }, + + sameHostClones: async ({ page: _page }, provide) => { + const firstRepo = await createTempGitRepo("grouped-clone-first-", { + originUrl: SHARED_REMOTE_URL, + }); + const secondRepo = await createTempGitRepo("grouped-clone-second-", { + originUrl: SHARED_REMOTE_URL, + }); + const client = await connectSeedClient(); + const projects: CreatedProject[] = []; + + try { + projects.push( + await createProject(client, { + projectPath: firstRepo.path, + serverId: getServerId(), + workspaceName: "First clone workspace", + projectName: "First clone", + }), + ); + projects.push( + await createProject(client, { + projectPath: secondRepo.path, + serverId: getServerId(), + workspaceName: "Second clone workspace", + projectName: "Second clone", + }), + ); + await provide({ hosts: [] }); + } finally { + for (const project of projects) { + await client.removeProject(project.projectId).catch(() => undefined); + } + await client.close().catch(() => undefined); + await firstRepo.cleanup().catch(() => undefined); + await secondRepo.cleanup().catch(() => undefined); + } + }, +}); + +async function openScenario( + page: Parameters[0], + scenario: ProjectDirectoryScenario, +): Promise { + if (scenario.hosts.length === 0) { + await openProjectDirectory(page); + return; + } + await openProjectDirectoryWithHosts(page, scenario); +} + +test.describe("Sidebar project grouping", () => { + test.describe.configure({ timeout: 120_000 }); + + test("groups projects with the same Git remote across hosts", async ({ + page, + crossHostProject, + }) => { + await openScenario(page, crossHostProject); + await expectProjectContainsWorkspaces(page, { + projectName: GROUPED_PROJECT_NAME, + workspaceNames: ["Primary workspace", "Secondary workspace"], + }); + }); + + test("groups persisted projects missing project keys after boot", async ({ + page, + reconciledCrossHostProject, + }) => { + await openScenario(page, reconciledCrossHostProject); + await expectProjectContainsWorkspaces(page, { + projectName: GROUPED_PROJECT_NAME, + workspaceNames: ["Recovered primary workspace", "Recovered secondary workspace"], + }); + }); + + test("keeps a repository root and its subdirectory as separate projects", async ({ + page, + rootAndSubdirectoryProjects, + }) => { + await openScenario(page, rootAndSubdirectoryProjects); + await expectSeparateProjects(page, [ + { projectName: "Repository root project", workspaceName: "Repository root workspace" }, + { projectName: "App package project", workspaceName: "App package workspace" }, + ]); + }); + + test("groups the same repository subdirectory across hosts", async ({ + page, + crossHostSubdirectoryProject, + }) => { + await openScenario(page, crossHostSubdirectoryProject); + await expectProjectContainsWorkspaces(page, { + projectName: GROUPED_PROJECT_NAME, + workspaceNames: ["Primary app workspace", "Secondary app workspace"], + }); + }); + + test("keeps two clones of the same repository on one host separate", async ({ + page, + sameHostClones, + }) => { + await openScenario(page, sameHostClones); + await expectSeparateProjects(page, [ + { projectName: "First clone", workspaceName: "First clone workspace" }, + { projectName: "Second clone", workspaceName: "Second clone workspace" }, + ]); + }); + + test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => { + await openScenario(page, crossHostProject); + await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME); + await renameProject(page, "Primary-only project name"); + await expectProjectSettingsName(page, "Primary-only project name"); + await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL); + await expectProjectSettingsName(page, GROUPED_PROJECT_NAME); + }); + + test("creates a workspace on the selected host from a grouped project", async ({ + page, + crossHostProject, + }) => { + await openScenario(page, crossHostProject); + await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME); + await selectWorkspaceHost(page, SECONDARY_HOST_LABEL); + await createWorkspaceWithoutAgent(page); + await expectProjectWorkspaceCountForHost(page, { + projectName: GROUPED_PROJECT_NAME, + hostName: SECONDARY_HOST_LABEL, + count: 2, + }); + }); +}); diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 223a0cce3d..7494fa2394 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -219,7 +219,7 @@ test.describe("Half-screen desktop layout", () => { } await gotoAppShell(page); - await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click(); + await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click(); await waitForSidebarWorkspace(page, lastWorkspaceId); const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll"); diff --git a/packages/app/e2e/workspace-model-restart.spec.ts b/packages/app/e2e/workspace-model-restart.spec.ts index 3f459bf463..5c799ae005 100644 --- a/packages/app/e2e/workspace-model-restart.spec.ts +++ b/packages/app/e2e/workspace-model-restart.spec.ts @@ -32,8 +32,12 @@ interface RestartDaemonClient { name: string; status: string; workspaceDirectory: string; + projectId: string; }>; }>; + listProjects(): Promise<{ + projects: Array<{ projectId: string; projectKey?: string }>; + }>; fetchAgents(options?: { scope?: "active" }): Promise<{ entries: Array<{ agent: { @@ -461,9 +465,16 @@ test.describe("Workspace model restart regressions", () => { .poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 }) .toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`); + const reconciledProjectKey = (await client.listProjects()).projects.find( + (project) => project.projectId === seeded.projectId, + ); + if (!reconciledProjectKey?.projectKey) { + throw new Error(`Workspace ${seeded.workspaceA} was not reconciled with a project key`); + } + await openGlobalNewWorkspaceComposer(page); await selectNewWorkspaceProject(page, { - projectKey: seeded.projectId, + projectKey: reconciledProjectKey.projectKey, projectDisplayName: seeded.projectDisplayName, }); await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName); diff --git a/packages/app/e2e/workspace-multiplicity.spec.ts b/packages/app/e2e/workspace-multiplicity.spec.ts index 1e5066cbe6..bafc4407ad 100644 --- a/packages/app/e2e/workspace-multiplicity.spec.ts +++ b/packages/app/e2e/workspace-multiplicity.spec.ts @@ -84,7 +84,7 @@ test.describe("Workspace multiplicity creation flow", () => { try { const project = { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }; @@ -136,7 +136,7 @@ test.describe("Workspace multiplicity creation flow", () => { try { const project = { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }; @@ -182,7 +182,7 @@ test.describe("Workspace multiplicity creation flow", () => { try { const project = { - projectKey: seeded.projectId, + projectKey: seeded.projectKey, projectDisplayName: seeded.projectDisplayName, }; @@ -190,7 +190,7 @@ test.describe("Workspace multiplicity creation flow", () => { await waitForSidebarHydration(page); // Model B: a non-git project is an expandable parent like any other, with // its single workspace already rendered as its own row underneath. - await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({ + await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ timeout: 30_000, }); await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ @@ -210,7 +210,7 @@ test.describe("Workspace multiplicity creation flow", () => { // Both the original and the new workspace render as distinct rows under // the same expandable parent. - await expect(page.getByTestId(`sidebar-project-row-${seeded.projectId}`)).toBeVisible({ + await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ timeout: 30_000, }); await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index febae56d9d..037592558c 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -883,7 +883,7 @@ function RootStack() { - + diff --git a/packages/app/src/app/settings/projects/[projectKey].tsx b/packages/app/src/app/settings/projects/[projectKey].tsx deleted file mode 100644 index 5fe6f442a9..0000000000 --- a/packages/app/src/app/settings/projects/[projectKey].tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { useLocalSearchParams } from "expo-router"; -import { useMemo } from "react"; -import SettingsScreen from "@/screens/settings-screen"; - -export default function SettingsProjectDetailRoute() { - const params = useLocalSearchParams<{ projectKey?: string | string[] }>(); - const rawProjectKey = Array.isArray(params.projectKey) ? params.projectKey[0] : params.projectKey; - const projectKey = typeof rawProjectKey === "string" ? decodeURIComponent(rawProjectKey) : ""; - const view = useMemo(() => ({ kind: "project" as const, projectKey }), [projectKey]); - - return ; -} diff --git a/packages/app/src/app/settings/projects/[serverId]/[projectId].tsx b/packages/app/src/app/settings/projects/[serverId]/[projectId].tsx new file mode 100644 index 0000000000..d967bcef47 --- /dev/null +++ b/packages/app/src/app/settings/projects/[serverId]/[projectId].tsx @@ -0,0 +1,19 @@ +import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; +import SettingsScreen from "@/screens/settings-screen"; +import { normalizeProjectSettingsRouteId } from "@/utils/host-routes"; + +export default function SettingsProjectDetailRoute() { + const params = useLocalSearchParams<{ + serverId?: string | string[]; + projectId?: string | string[]; + }>(); + const serverId = normalizeProjectSettingsRouteId(params.serverId); + const projectId = normalizeProjectSettingsRouteId(params.projectId); + const view = useMemo( + () => ({ kind: "project" as const, serverId, projectId }), + [projectId, serverId], + ); + + return ; +} diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 45425fde89..c36a583cbe 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -365,7 +365,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const recommendedPaths = useRecommendedProjectPaths(hostId); const openProject = useOpenProject(hostId); const cloneGithubProject = useCloneGithubProject(hostId); - const addEmptyProject = useSessionStore((store) => store.addEmptyProject); + const upsertProject = useSessionStore((store) => store.upsertProject); const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces); const inputRef = useRef(null); const submissionInFlightRef = useRef(false); @@ -735,7 +735,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { registerProjectDescriptor({ serverId: page.hostId, project: payload.project, - addEmptyProject, + upsertProject, setHasHydratedWorkspaces, }); openNewWorkspaceForProject(page.hostId, payload.project); @@ -749,7 +749,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { } finally { submissionInFlightRef.current = false; } - }, [addEmptyProject, client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces]); + }, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]); const submitActive = useCallback(() => { if (page.kind === "new-directory-name") { diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 0314e54dd2..972d46bc18 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -38,6 +38,7 @@ import { } from "@/runtime/host-runtime"; import type { HostProfile } from "@/types/host-connection"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { seedSessionWorkspaces } from "@/test/seed-session"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -164,9 +165,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { act(() => { setHostProfiles([makeHost()]); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - useSessionStore - .getState() - .setWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); + seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index d988b880ac..59bc75afd7 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -115,6 +115,7 @@ import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel, resolveSidebarProjectIconTarget, + resolveSidebarProjectLocalPath, type SidebarProjectHostTarget, } from "@/utils/sidebar-project-row-model"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; @@ -132,6 +133,7 @@ import { } from "@/constants/platform"; import { getDesktopHost } from "@/desktop/host"; import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item"; +import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; @@ -505,6 +507,9 @@ function ProjectRowTrailingActions({ removeProjectStatus: "idle" | "pending" | "success"; }) { const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint; + const localDaemonServerId = useLocalDaemonServerId(); + const localProjectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId); + const settingsTarget = project.hosts[0] ?? null; return ( {worktreeTarget ? ( @@ -523,7 +528,8 @@ function ProjectRowTrailingActions({ > @@ -550,11 +556,13 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { function ProjectKebabMenu({ projectKey, + settingsTarget, projectPath, onRemoveProject, removeProjectStatus, }: { projectKey: string; + settingsTarget: { serverId: string; projectId: string } | null; projectPath: string; onRemoveProject: () => void; removeProjectStatus: "idle" | "pending" | "success"; @@ -562,10 +570,10 @@ function ProjectKebabMenu({ const { t } = useTranslation(); const toast = useToast(); const handleOpenProjectSettings = useCallback(() => { - if (projectKey.trim().length === 0) return; - router.navigate(buildProjectSettingsRoute(projectKey)); - }, [projectKey]); - const canOpenProjectSettings = projectKey.trim().length > 0; + if (!settingsTarget) return; + router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId)); + }, [settingsTarget]); + const canOpenProjectSettings = settingsTarget !== null; // Desktop-only: open a second window that lands on this project via the same // open-project flow as a CLI launch. The project stays visible here too — no // ownership, no move. @@ -908,10 +916,10 @@ function NewWorkspaceGhostRow({ serverId: worktreeTarget.serverId, sourceDirectory: worktreeTarget.iconWorkingDir, displayName, - projectId: project.projectKey, + projectId: worktreeTarget.projectId, }) as Href, ); - }, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]); + }, [displayName, onWorkspacePress, worktreeTarget]); const rowStyle = useCallback( ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.newWorkspaceGhostRow, @@ -988,10 +996,10 @@ function ProjectHeaderRow({ serverId: worktreeTarget.serverId, sourceDirectory: worktreeTarget.iconWorkingDir, displayName, - projectId: project.projectKey, + projectId: worktreeTarget.projectId, }) as Href, ); - }, [displayName, onWorkspacePress, project.projectKey, worktreeTarget]); + }, [displayName, onWorkspacePress, worktreeTarget]); const interaction = useLongPressDragInteraction({ drag, menuController, @@ -1755,7 +1763,6 @@ function ProjectBlock({ } void removeProjectFromHosts({ - projectKey: project.projectKey, targets: readiness.targets, getClient: (serverId) => getHostRuntimeStore().getClient(serverId), }) @@ -1824,7 +1831,7 @@ function ProjectBlock({ } return ( - + + {leadingContent} diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index ba46840b2a..5f3bd629f6 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { seedSessionWorkspaces } from "@/test/seed-session"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; @@ -93,7 +94,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { act(() => { setHostProfiles([hostProfile()]); useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient); - useSessionStore.getState().setWorkspaces( + seedSessionWorkspaces( "srv", new Map([ ["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })], @@ -139,7 +140,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { it("publishes status-mode shortcut targets in visual status order", async () => { act(() => { useSidebarViewStore.getState().setGroupMode("status"); - useSessionStore.getState().setWorkspaces( + seedSessionWorkspaces( "srv", new Map([ [ @@ -211,18 +212,14 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]); useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient); useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient); - useSessionStore - .getState() - .setWorkspaces( - "host-a", - new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]), - ); - useSessionStore - .getState() - .setWorkspaces( - "host-b", - new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]), - ); + seedSessionWorkspaces( + "host-a", + new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]), + ); + seedSessionWorkspaces( + "host-b", + new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]), + ); useSessionStore.getState().setHasHydratedWorkspaces("host-a", true); useSessionStore.getState().setHasHydratedWorkspaces("host-b", true); useSidebarViewStore.getState().toggleHostFilter("host-b"); diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts index 03257be24b..2cd7476d62 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.test.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -3,101 +3,56 @@ import { buildWorktreeSetupCalloutPolicy, selectActiveGitWorkspaceProject, shouldShowWorktreeSetupCallout, - type WorktreeSetupWorkspaceInput, } from "./worktree-setup-callout-policy"; -function gitWorkspace( - overrides: Partial = {}, -): WorktreeSetupWorkspaceInput { - return { - projectId: "project-1", - projectKind: "git", - projectRootPath: "/repo/project-1", - project: { checkout: { mainRepoRoot: "/repo/main-project-1" } }, - ...overrides, - }; -} - describe("selectActiveGitWorkspaceProject", () => { - it("selects the active git workspace project from checkout metadata", () => { - expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace())).toEqual({ - serverId: "server-1", - projectKey: "project-1", - repoRoot: "/repo/main-project-1", - }); - }); - - it("falls back to the workspace project root when checkout metadata has no main root", () => { + it("selects the host-local project id for a git workspace", () => { expect( - selectActiveGitWorkspaceProject( - "server-1", - gitWorkspace({ project: { checkout: { mainRepoRoot: null } } }), - ), + selectActiveGitWorkspaceProject("server-1", { + projectId: "prj_local", + projectKind: "git", + projectRootPath: "/repo/project", + }), ).toEqual({ serverId: "server-1", - projectKey: "project-1", - repoRoot: "/repo/project-1", + projectId: "prj_local", + repoRoot: "/repo/project", }); }); - it("ignores non-git workspaces and blank project coordinates", () => { + it("ignores non-git workspaces and blank coordinates", () => { expect( - selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectKind: "local" })), - ).toBe(null); - expect(selectActiveGitWorkspaceProject("server-1", gitWorkspace({ projectId: " " }))).toBe( - null, - ); - expect( - selectActiveGitWorkspaceProject( - "server-1", - gitWorkspace({ projectRootPath: " ", project: null }), - ), - ).toBe(null); + selectActiveGitWorkspaceProject("server-1", { + projectId: "project", + projectKind: "directory", + projectRootPath: "/repo", + }), + ).toBeNull(); }); }); describe("shouldShowWorktreeSetupCallout", () => { - it("shows the callout when paseo config was read and setup commands are missing", () => { + it("shows only after a successful config read with no setup command", () => { expect(shouldShowWorktreeSetupCallout({ ok: true, config: {} })).toBe(true); - expect(shouldShowWorktreeSetupCallout({ ok: true, config: null })).toBe(true); - }); - - it("does not show the callout when setup commands are present", () => { expect( - shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm install" } } }), + shouldShowWorktreeSetupCallout({ ok: true, config: { worktree: { setup: "npm i" } } }), ).toBe(false); - expect( - shouldShowWorktreeSetupCallout({ - ok: true, - config: { worktree: { setup: [" ", "npm install"] } }, - }), - ).toBe(false); - }); - - it("does not show the callout when reading paseo config fails or has not completed", () => { - expect(shouldShowWorktreeSetupCallout(undefined)).toBe(false); expect(shouldShowWorktreeSetupCallout({ ok: false })).toBe(false); }); }); describe("buildWorktreeSetupCalloutPolicy", () => { - it("builds the stable sidebar callout identity and action route", () => { + it("routes by server id and project id", () => { expect( buildWorktreeSetupCalloutPolicy({ serverId: "server-1", - projectKey: "project-1", - repoRoot: "/repo/project-1", + projectId: "prj_local", + repoRoot: "/repo/project", }), - ).toEqual({ - id: "worktree-setup-missing:project-1", - dismissalKey: "worktree-setup-missing:project-1", - priority: 100, - title: "Set up worktree scripts", - description: - "Add setup commands so new worktrees can install dependencies and prepare themselves automatically.", - actionLabel: "Open project settings", - projectSettingsRoute: "/settings/projects/project-1", - testID: "worktree-setup-callout-project-1", + ).toMatchObject({ + id: "worktree-setup-missing:server-1:prj_local", + projectSettingsRoute: "/settings/projects/server-1/prj_local", + testID: "worktree-setup-callout-prj_local", }); }); }); diff --git a/packages/app/src/components/worktree-setup-callout-policy.ts b/packages/app/src/components/worktree-setup-callout-policy.ts index 371af8f226..dd5814eef8 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.ts @@ -6,16 +6,11 @@ export interface WorktreeSetupWorkspaceInput { projectId: string; projectKind: string; projectRootPath: string; - project?: { - checkout?: { - mainRepoRoot?: string | null; - } | null; - } | null; } export interface ActiveGitWorkspaceProject { serverId: string; - projectKey: string; + projectId: string; repoRoot: string; } @@ -43,13 +38,13 @@ export function selectActiveGitWorkspaceProject( return null; } - const projectKey = workspace.projectId.trim(); - const repoRoot = (workspace.project?.checkout?.mainRepoRoot ?? workspace.projectRootPath).trim(); - if (!projectKey || !repoRoot) { + const projectId = workspace.projectId; + const repoRoot = workspace.projectRootPath.trim(); + if (!projectId.trim() || !repoRoot) { return null; } - return { serverId, projectKey, repoRoot }; + return { serverId, projectId, repoRoot }; } export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResult | undefined) { @@ -59,7 +54,7 @@ export function shouldShowWorktreeSetupCallout(readResult: ReadProjectConfigResu export function buildWorktreeSetupCalloutPolicy( project: ActiveGitWorkspaceProject, ): WorktreeSetupCalloutPolicy { - const calloutKey = `worktree-setup-missing:${project.projectKey}`; + const calloutKey = `worktree-setup-missing:${project.serverId}:${project.projectId}`; return { id: calloutKey, @@ -68,8 +63,8 @@ export function buildWorktreeSetupCalloutPolicy( title: i18n.t("sidebar.worktreeSetup.title"), description: i18n.t("sidebar.worktreeSetup.description"), actionLabel: i18n.t("sidebar.worktreeSetup.openProjectSettings"), - projectSettingsRoute: buildProjectSettingsRoute(project.projectKey), - testID: `worktree-setup-callout-${project.projectKey}`, + projectSettingsRoute: buildProjectSettingsRoute(project.serverId, project.projectId), + testID: `worktree-setup-callout-${project.projectId}`, }; } diff --git a/packages/app/src/components/worktree-setup-callout-source.tsx b/packages/app/src/components/worktree-setup-callout-source.tsx index 2cba8dfe63..b7ec157e20 100644 --- a/packages/app/src/components/worktree-setup-callout-source.tsx +++ b/packages/app/src/components/worktree-setup-callout-source.tsx @@ -14,11 +14,12 @@ import { export function WorktreeSetupCalloutSource() { const selection = useActiveWorkspaceSelection(); - const activeProject = useWorkspaceFields( + const selectedWorkspaceProject = useWorkspaceFields( selection?.serverId ?? null, selection?.workspaceId ?? null, (workspace) => selectActiveGitWorkspaceProject(selection?.serverId ?? "", workspace), ); + const activeProject = selectedWorkspaceProject; const client = useHostRuntimeClient(activeProject?.serverId ?? ""); const callouts = useSidebarCallouts(); const router = useRouter(); diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts index 0c929bab08..aae61c8b1d 100644 --- a/packages/app/src/hooks/open-project.ts +++ b/packages/app/src/hooks/open-project.ts @@ -4,10 +4,7 @@ import type { ProjectAddResponse, WorkspaceProjectDescriptorPayload, } from "@getpaseo/protocol/messages"; -import { - normalizeEmptyProjectDescriptor as normalizeProjectWithoutWorkspacesDescriptor, - type EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor, -} from "@/stores/session-store"; +import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store"; type OpenProjectPayload = ProjectAddResponse["payload"]; type OpenProjectErrorCode = NonNullable; @@ -47,28 +44,28 @@ export interface OpenProjectDirectlyInput { isConnected: boolean; canAddProject: boolean; client: Pick | null; - addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void; + upsertProject: (serverId: string, project: ProjectDescriptor) => void; setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; } interface ProjectRegistrationCallbacks { serverId: string; isConnected: boolean; - addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void; + upsertProject: (serverId: string, project: ProjectDescriptor) => void; setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; } export interface RegisterProjectDescriptorInput { serverId: string; project: WorkspaceProjectDescriptorPayload; - addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => void; + upsertProject: (serverId: string, project: ProjectDescriptor) => void; setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; } export function registerProjectDescriptor(input: RegisterProjectDescriptorInput): boolean { const serverId = input.serverId.trim(); if (!serverId) return false; - input.addEmptyProject(serverId, normalizeProjectWithoutWorkspacesDescriptor(input.project)); + input.upsertProject(serverId, normalizeProjectDescriptor(input.project)); input.setHasHydratedWorkspaces(serverId, true); return true; } @@ -109,7 +106,7 @@ export async function openProjectDirectly( const registered = registerProjectDescriptor({ serverId: normalizedServerId, project: payload.project, - addEmptyProject: input.addEmptyProject, + upsertProject: input.upsertProject, setHasHydratedWorkspaces: input.setHasHydratedWorkspaces, }); return registered @@ -145,7 +142,7 @@ export async function cloneGithubProjectDirectly( const registered = registerProjectDescriptor({ serverId: normalizedServerId, project: payload.project, - addEmptyProject: input.addEmptyProject, + upsertProject: input.upsertProject, setHasHydratedWorkspaces: input.setHasHydratedWorkspaces, }); return registered diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index f827c06207..9816485741 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -78,20 +78,25 @@ function project(input: { projectKind?: WorkspaceStructureProject["projectKind"]; iconWorkingDir?: string; workspaceKeys: string[]; - hosts?: WorkspaceStructureProject["hosts"]; + hosts?: Array< + Omit & { projectId?: string } + >; }): WorkspaceStructureProject { return { projectKey: input.projectKey, projectName: input.projectName ?? input.projectKey, projectKind: input.projectKind ?? "git", iconWorkingDir: input.iconWorkingDir ?? input.projectKey, - hosts: input.hosts ?? [ - { - serverId: "srv", - iconWorkingDir: input.iconWorkingDir ?? input.projectKey, - canCreateWorktree: true, - }, - ], + hosts: Array.from( + input.hosts ?? [ + { + serverId: "srv", + iconWorkingDir: input.iconWorkingDir ?? input.projectKey, + canCreateWorktree: true, + }, + ], + (host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }), + ), workspaceKeys: input.workspaceKeys, }; } @@ -324,8 +329,18 @@ describe("shared sidebar workspace model", () => { expect.objectContaining({ projectKey: "getpaseo/paseo", hosts: [ - { serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true }, - { serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true }, + { + serverId: "host-a", + projectId: "getpaseo/paseo", + iconWorkingDir: "/repo/getpaseo/paseo", + canCreateWorktree: true, + }, + { + serverId: "host-b", + projectId: "getpaseo/paseo", + iconWorkingDir: "/repo/getpaseo/paseo", + canCreateWorktree: true, + }, ], workspaces: [ expect.objectContaining({ @@ -401,6 +416,35 @@ describe("shared sidebar workspace model", () => { expect(nextEntries.get("srv:one")).toBe(previousEntries.get("srv:one")); expect(nextEntries.get("srv:two")).not.toBe(previousEntries.get("srv:two")); }); + + it("keeps a structurally disambiguated project key in status entries", () => { + const projectKey = "host:srv:project:prj_a"; + const model = buildSidebarWorkspacePlacementModel({ + projects: [project({ projectKey, projectName: "Clone A", workspaceKeys: ["srv:clone-a"] })], + }); + const entries = buildSidebarWorkspaceEntries({ + placements: model.workspaces, + sessions: [ + { + serverId: "srv", + workspaceAgentActivity: new Map(), + workspaces: new Map([ + [ + "clone-a", + workspace({ + id: "clone-a", + name: "main", + projectId: "prj_a", + projectDisplayName: "acme/app", + }), + ], + ]), + }, + ], + }); + + expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey); + }); }); describe("shouldShowSidebarHostLabels", () => { diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 0d279f13ef..937111e79d 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -141,10 +141,11 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin export function createSidebarWorkspaceEntry(input: { serverId: string; workspace: WorkspaceDescriptor; + projectKey?: string; pendingCreateAttempts?: Record; workspaceAgentActivity?: ReadonlyMap; }): SidebarWorkspaceEntry { - const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId; + const projectKey = input.projectKey ?? input.workspace.projectId; const effectiveStatus = deriveEffectiveWorkspaceStatus(input); return { workspaceKey: `${input.serverId}:${input.workspace.id}`, @@ -323,6 +324,7 @@ export function buildSidebarWorkspaceEntries(input: { const entry = createSidebarWorkspaceEntry({ serverId: placement.serverId, workspace, + projectKey: placement.projectKey, pendingCreateAttempts: input.pendingCreateAttempts, workspaceAgentActivity: session.workspaceAgentActivity, }); diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index 55bf4a3544..e502f3b374 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -4,7 +4,7 @@ import { getOpenProjectFailureReason, openProjectDirectly, } from "@/hooks/open-project"; -import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store"; +import type { ProjectDescriptor } from "@/stores/session-store"; const SERVER_ID = "server-1"; const PROJECT_PATH = "/repo/project"; @@ -20,7 +20,7 @@ function buildProjectPayload() { interface RecordedProject { serverId: string; - project: ProjectWithoutWorkspacesDescriptor; + project: ProjectDescriptor; } interface RecordedHydrated { @@ -40,7 +40,7 @@ function createFakeSession() { return { projects, hydrated, - addEmptyProject: (serverId: string, project: ProjectWithoutWorkspacesDescriptor) => { + upsertProject: (serverId: string, project: ProjectDescriptor) => { projects.push({ serverId, project }); }, setHasHydratedWorkspaces: (serverId: string, value: boolean) => { @@ -83,7 +83,7 @@ describe("openProjectDirectly", () => { project: projectPayload, }), }, - addEmptyProject: session.addEmptyProject, + upsertProject: session.upsertProject, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, }); @@ -93,6 +93,7 @@ describe("openProjectDirectly", () => { serverId: SERVER_ID, project: { projectId: "project-1", + projectKey: null, projectDisplayName: "project", projectCustomName: null, projectKind: "git", @@ -117,7 +118,7 @@ describe("openProjectDirectly", () => { project: buildProjectPayload(), }), }, - addEmptyProject: session.addEmptyProject, + upsertProject: session.upsertProject, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, }); @@ -146,7 +147,7 @@ describe("openProjectDirectly", () => { project: null, }), }, - addEmptyProject: session.addEmptyProject, + upsertProject: session.upsertProject, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, }); @@ -173,7 +174,7 @@ describe("cloneGithubProjectDirectly", () => { cloneProtocol: "https", isConnected: true, client: github, - addEmptyProject: session.addEmptyProject, + upsertProject: session.upsertProject, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, }); @@ -191,6 +192,7 @@ describe("cloneGithubProjectDirectly", () => { project: { ...projectPayload, projectCustomName: null, + projectKey: null, }, }, ]); @@ -208,7 +210,7 @@ describe("cloneGithubProjectDirectly", () => { cloneProtocol: "https", isConnected: true, client: github, - addEmptyProject: session.addEmptyProject, + upsertProject: session.upsertProject, setHasHydratedWorkspaces: session.setHasHydratedWorkspaces, }); diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 98bd07295b..71a0a78da2 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -20,7 +20,7 @@ export function useOpenProject( state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true : false, ); - const addEmptyProject = useSessionStore((state) => state.addEmptyProject); + const upsertProject = useSessionStore((state) => state.upsertProject); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( @@ -31,13 +31,13 @@ export function useOpenProject( isConnected, canAddProject, client, - addEmptyProject, + upsertProject, setHasHydratedWorkspaces, }); return result; }, [ - addEmptyProject, + upsertProject, canAddProject, client, isConnected, @@ -57,7 +57,7 @@ export function useCloneGithubProject( const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const addEmptyProject = useSessionStore((state) => state.addEmptyProject); + const upsertProject = useSessionStore((state) => state.upsertProject); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( @@ -69,10 +69,10 @@ export function useCloneGithubProject( ...(cloneProtocol ? { cloneProtocol } : {}), isConnected, client, - addEmptyProject, + upsertProject, setHasHydratedWorkspaces, }); }, - [addEmptyProject, client, isConnected, normalizedServerId, setHasHydratedWorkspaces], + [client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject], ); } diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 13c480315c..9fc5e687be 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -1,242 +1,90 @@ import { describe, expect, it } from "vitest"; -import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; +import type { ProjectDescriptor } from "@/stores/session-store"; import { deriveProjectsFromReplica, type ProjectHostReplica, type ProjectHostRuntimeState, } from "@/hooks/use-projects"; -function runtimeState(input: { - serverId: string; - isOnline: boolean; - isLoading?: boolean; - isFetching?: boolean; - error?: string | null; -}): ProjectHostRuntimeState { +function project(id: string, name: string, root: string): ProjectDescriptor { + return { + projectId: id, + projectKey: `remote:github.com/${name}`, + projectDisplayName: name, + projectCustomName: null, + projectRootPath: root, + projectKind: "git", + }; +} + +function runtimeState(input: Partial & { serverId: string }) { return { serverId: input.serverId, - isOnline: input.isOnline, + isOnline: input.isOnline ?? true, isLoading: input.isLoading ?? false, isFetching: input.isFetching ?? false, error: input.error ?? null, }; } -function workspace(input: { - id: string; - projectKey: string; - projectName: string; - cwd: string; - remoteUrl: string | null; -}): WorkspaceDescriptor { - return { - id: input.id, - projectId: input.projectKey, - projectDisplayName: input.projectName, - projectCustomName: null, - projectRootPath: input.cwd, - workspaceDirectory: input.cwd, - projectKind: "git", - workspaceKind: "local_checkout", - name: input.id, - title: null, - archivingAt: null, - status: "done", - statusEnteredAt: null, - diffStat: null, - scripts: [], - gitRuntime: { - currentBranch: "main", - remoteUrl: input.remoteUrl, - isPaseoOwnedWorktree: false, - isDirty: false, - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - }, - githubRuntime: null, - project: { - projectKey: input.projectKey, - projectName: input.projectName, - checkout: { - cwd: input.cwd, - isGit: true, - currentBranch: "main", - remoteUrl: input.remoteUrl, - worktreeRoot: input.cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }, - }; -} - -function emptyProject(input: { - projectKey: string; - projectName: string; - cwd: string; -}): EmptyProjectDescriptor { - return { - projectId: input.projectKey, - projectDisplayName: input.projectName, - projectCustomName: null, - projectRootPath: input.cwd, - projectKind: "git", - }; -} - describe("deriveProjectsFromReplica", () => { - it("aggregates store workspaces and empty projects sorted by display name", () => { + it("derives projects from the project descriptor replica", () => { const replicas: ProjectHostReplica[] = [ { serverId: "local", serverName: "Local", - workspaces: [ - workspace({ - id: "z-main", - projectKey: "remote:github.com/acme/zeta", - projectName: "acme/zeta", - cwd: "/repo/zeta", - remoteUrl: "https://github.com/acme/zeta.git", - }), - ], - emptyProjects: [], + workspaces: [], + projects: [project("prj_zeta", "acme/zeta", "/repo/zeta")], }, { serverId: "laptop", serverName: "Laptop", workspaces: [], - emptyProjects: [ - emptyProject({ - projectKey: "remote:github.com/acme/alpha", - projectName: "acme/alpha", - cwd: "/repo/alpha", - }), - ], + projects: [project("prj_alpha", "acme/alpha", "/repo/alpha")], }, ]; const result = deriveProjectsFromReplica({ replicas, runtimeStates: [ - runtimeState({ serverId: "local", isOnline: true }), + runtimeState({ serverId: "local" }), runtimeState({ serverId: "laptop", isOnline: false }), ], }); - expect(result.projects.map((project) => project.projectName)).toEqual([ - "acme/alpha", - "acme/zeta", - ]); - expect(result.projects[0]?.hosts).toEqual([ - expect.objectContaining({ - serverId: "laptop", - isOnline: false, - repoRoot: "/repo/alpha", - workspaceCount: 0, - }), - ]); - expect(result.hostErrors).toEqual([]); - }); - - it("maps runtime directory status into loading and host errors", () => { - const replicas: ProjectHostReplica[] = [ - { - serverId: "local", - serverName: "Local", - workspaces: [ - workspace({ - id: "main", - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - ], - emptyProjects: [], - }, - { - serverId: "laptop", - serverName: "Laptop", - workspaces: [], - emptyProjects: [], - }, - ]; - - const result = deriveProjectsFromReplica({ - replicas, - runtimeStates: [ - runtimeState({ serverId: "local", isOnline: true, isFetching: true }), - runtimeState({ - serverId: "laptop", - isOnline: true, - isLoading: true, - error: "laptop unavailable", - }), - ], + expect(result.projects.map((item) => item.projectName)).toEqual(["acme/alpha", "acme/zeta"]); + expect(result.projects[0]?.hosts[0]).toMatchObject({ + serverId: "laptop", + projectId: "prj_alpha", + repoRoot: "/repo/alpha", + workspaceCount: 0, }); - - expect(result.projects).toEqual([ - expect.objectContaining({ projectKey: "remote:github.com/acme/app" }), - ]); - expect(result.hostErrors).toEqual([ - { - serverId: "laptop", - serverName: "Laptop", - message: "laptop unavailable", - }, - ]); - expect(result.isLoading).toBe(true); - expect(result.isFetching).toBe(true); }); - it("returns only the stable public project and host entry shapes", () => { + it("preserves runtime loading and error state", () => { const result = deriveProjectsFromReplica({ replicas: [ { serverId: "local", serverName: "Local", - workspaces: [ - workspace({ - id: "main", - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - ], - emptyProjects: [], + workspaces: [], + projects: [project("prj_app", "acme/app", "/repo/app")], }, ], - runtimeStates: [runtimeState({ serverId: "local", isOnline: true })], + runtimeStates: [ + runtimeState({ + serverId: "local", + isLoading: true, + isFetching: true, + error: "unavailable", + }), + ], }); - expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([ - "githubUrl", - "hostCount", - "hosts", - "onlineHostCount", - "projectCustomName", - "projectKey", - "projectName", - "totalWorkspaceCount", - ]); - expect(Object.keys(result.projects[0]?.hosts[0] ?? {}).sort()).toEqual([ - "gitRuntime", - "githubRuntime", - "isOnline", - "repoRoot", - "serverId", - "serverName", - "workspaceCount", - "workspaces", - ]); - expect(Object.keys(result.projects[0]?.hosts[0]?.workspaces[0] ?? {}).sort()).toEqual([ - "currentBranch", - "id", - "name", - "status", - "workspaceKind", + expect(result.isLoading).toBe(true); + expect(result.isFetching).toBe(true); + expect(result.hostErrors).toEqual([ + { serverId: "local", serverName: "Local", message: "unavailable" }, ]); }); }); diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index d6fc3803ff..c5d0359a5e 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -9,7 +9,7 @@ import { } from "@/runtime/host-runtime"; import { useSessionStore, - type EmptyProjectDescriptor, + type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects"; @@ -24,7 +24,7 @@ export interface ProjectHostReplica { serverId: string; serverName: string; workspaces: WorkspaceDescriptor[]; - emptyProjects: EmptyProjectDescriptor[]; + projects: ProjectDescriptor[]; } export interface ProjectHostRuntimeState { @@ -87,7 +87,7 @@ function selectProjectHostReplicas( serverId: host.serverId, serverName: host.label, workspaces: Array.from(session?.workspaces.values() ?? []), - emptyProjects: Array.from(session?.emptyProjects.values() ?? []), + projects: Array.from(session?.projects.values() ?? []), }; }); } @@ -106,7 +106,7 @@ export function deriveProjectsFromReplica(input: { serverName: replica.serverName, isOnline: runtimeState?.isOnline ?? false, workspaces: replica.workspaces, - emptyProjects: replica.emptyProjects, + projects: replica.projects, }; }); const hostErrors = input.replicas.flatMap((replica) => { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 92ae9d3671..a47d6d2772 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,12 +1,9 @@ import { useCallback, useEffect, useMemo } from "react"; -import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks"; import { useHostProjects } from "@/projects/host-projects"; -import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching"; import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; -import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts"; import { buildSidebarWorkspacePlacementModel, computeSidebarOrderUpdates, @@ -128,35 +125,12 @@ export function useSidebarWorkspacesList(options?: { for (const serverId of serverIds) { const snapshot = runtime.getSnapshot(serverId); if (snapshot?.connectionStatus !== "online") continue; - const client = runtime.getClient(serverId); - if (!client) continue; - void (async () => { - const next = new Map(); - try { - const { workspaces, emptyProjects } = await fetchAllWorkspaceDescriptors({ - client, - sort: [{ key: "activity_at", direction: "desc" }], - }); - for (const workspace of workspaces) { - if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { - continue; - } - next.set(workspace.id, workspace); - } - const store = useSessionStore.getState(); - store.setWorkspaces(serverId, next); - // Keep parents with no workspaces yet, so a manual refresh doesn't drop - // a freshly-added project from the sidebar. - store.setEmptyProjects(serverId, emptyProjects); - store.setHasHydratedWorkspaces(serverId, true); - } catch (error) { - console.error("[WorkspaceFetch][sidebar-refresh] failed", { - serverId, - error, - }); - // ignore explicit refresh failures; hook keeps existing data - } - })(); + void runtime.refreshDirectories(serverId).catch((error) => { + console.error("[WorkspaceFetch][sidebar-refresh] failed", { + serverId, + error, + }); + }); } }, [isActive, runtime, serverIds]); diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts index 04b001caf2..f87c0a19ea 100644 --- a/packages/app/src/projects/host-project-model.ts +++ b/packages/app/src/projects/host-project-model.ts @@ -4,14 +4,7 @@ import type { WorkspaceStructureProject, } from "@/projects/workspace-structure"; -export interface HostProjectListItem { - projectKey: string; - projectName: string; - projectKind: WorkspaceDescriptor["projectKind"]; - iconWorkingDir: string; - hosts: WorkspaceStructureHostPlacement[]; - workspaceKeys: string[]; -} +export type HostProjectListItem = WorkspaceStructureProject; export interface HostProjectRouteContext { serverId: string; @@ -31,33 +24,21 @@ export function canCreateWorktreeForProjectKind( return projectKind === "git"; } -export function buildHostProjectList(input: { - projects: readonly WorkspaceStructureProject[]; -}): HostProjectListItem[] { - return input.projects.map((project) => ({ - projectKey: project.projectKey, - projectName: project.projectName, - projectKind: project.projectKind, - iconWorkingDir: project.iconWorkingDir, - hosts: project.hosts, - workspaceKeys: project.workspaceKeys, - })); -} - export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null { - const projectKey = trimOptional(route.projectId); + const projectId = route.projectId?.trim() || undefined; const iconWorkingDir = trimOptional(route.sourceDirectory); - if (!projectKey || !iconWorkingDir) { + if (!projectId || !iconWorkingDir) { return null; } return { - projectKey, - projectName: trimOptional(route.displayName) ?? projectKey, + projectKey: projectId, + projectName: trimOptional(route.displayName) || projectId, projectKind: "git", iconWorkingDir, hosts: [ { serverId: route.serverId, + projectId, iconWorkingDir, canCreateWorktree: true, }, @@ -73,20 +54,24 @@ export function hostProjectFromWorkspace(input: { if (!input.workspace) { return null; } - const projectKey = input.workspace.projectId.trim(); + const projectId = input.workspace.projectId.trim() || undefined; + if (!projectId) { + return null; + } const iconWorkingDir = input.workspace.projectRootPath.trim(); - if (!projectKey || !iconWorkingDir) { + if (!iconWorkingDir) { return null; } const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind); return { - projectKey, - projectName: input.workspace.projectDisplayName || projectKey, + projectKey: projectId, + projectName: input.workspace.projectDisplayName || projectId, projectKind: input.workspace.projectKind, iconWorkingDir, hosts: [ { serverId: input.serverId, + projectId: input.workspace.projectId, iconWorkingDir, canCreateWorktree: canCreate, }, @@ -99,11 +84,25 @@ function projectCanCreateWorktree(project: HostProjectListItem): boolean { return project.hosts.some((h) => h.canCreateWorktree); } +function getHostProjectPlacement( + project: HostProjectListItem, + serverId: string, +): WorkspaceStructureHostPlacement | null { + for (const host of project.hosts) { + if (host.serverId === serverId) return host; + } + return null; +} + export function getHostProjectSourceDirectory( project: HostProjectListItem, serverId: string, ): string | null { - return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null; + return getHostProjectPlacement(project, serverId)?.iconWorkingDir ?? null; +} + +export function getHostProjectId(project: HostProjectListItem, serverId: string): string | null { + return getHostProjectPlacement(project, serverId)?.projectId ?? null; } export function canCreateWorkspaceForHostProject(input: { @@ -111,7 +110,7 @@ export function canCreateWorkspaceForHostProject(input: { serverId: string; allowAllProjects: boolean; }): boolean { - const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId); + const host = getHostProjectPlacement(input.project, input.serverId); if (!host) { return false; } @@ -144,8 +143,20 @@ export function resolveInitialWorkspaceProject(input: { if (!candidate) { continue; } + const candidatePlacement = candidate.hosts.find( + (host) => host.serverId === input.serverId && host.projectId, + ); const hydratedProject = - input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate; + (candidatePlacement + ? input.projects.find((project) => + project.hosts.some( + (host) => + host.serverId === input.serverId && host.projectId === candidatePlacement.projectId, + ), + ) + : undefined) ?? + input.projects.find((project) => project.projectKey === candidate.projectKey) ?? + candidate; if ( canCreateWorkspaceForHostProject({ project: hydratedProject, diff --git a/packages/app/src/projects/host-projects.test.ts b/packages/app/src/projects/host-projects.test.ts index 9e7fbcd73d..c16deb34fc 100644 --- a/packages/app/src/projects/host-projects.test.ts +++ b/packages/app/src/projects/host-projects.test.ts @@ -1,312 +1,64 @@ -import { describe, expect, it } from "vitest"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; -import type { WorkspaceStructureProject } from "@/projects/workspace-structure"; +import { describe, expect, test } from "vitest"; +import type { HostProjectListItem } from "./host-project-model"; import { - buildHostProjectList, canCreateWorkspaceForHostProject, - canCreateWorktreeForProjectKind, - filterWorkspaceProjectsForHost, + getHostProjectId, getHostProjectSourceDirectory, hostProjectFromRoute, - hostProjectFromWorkspace, - resolveInitialWorkspaceProject, - resolveInitialWorktreeProject, - resolveSelectedHostProject, - type HostProjectListItem, } from "./host-project-model"; -function structureProject(input: Partial): WorkspaceStructureProject { +function project(): HostProjectListItem { return { - projectKey: input.projectKey ?? "project-a", - projectName: input.projectName ?? "Project A", - projectKind: input.projectKind ?? "git", - iconWorkingDir: input.iconWorkingDir ?? "/repo/a", - hosts: input.hosts ?? [ + projectKey: "remote:github.com/acme/app", + projectName: "acme/app", + projectKind: "git", + iconWorkingDir: "/repo/a", + hosts: [ { serverId: "host-a", - iconWorkingDir: input.iconWorkingDir ?? "/repo/a", - canCreateWorktree: input.projectKind !== "directory", + projectId: "prj_a", + iconWorkingDir: "/repo/a", + canCreateWorktree: true, }, - ], - workspaceKeys: input.workspaceKeys ?? ["workspace-a"], - }; -} - -function hostProject(input: Partial): HostProjectListItem { - return { - projectKey: input.projectKey ?? "project-a", - projectName: input.projectName ?? "Project A", - projectKind: input.projectKind ?? "git", - iconWorkingDir: input.iconWorkingDir ?? "/repo/a", - hosts: input.hosts ?? [ { - serverId: "host-a", - iconWorkingDir: input.iconWorkingDir ?? "/repo/a", + serverId: "host-b", + projectId: "prj_b", + iconWorkingDir: "/repo/b", canCreateWorktree: true, }, ], - workspaceKeys: input.workspaceKeys ?? ["workspace-a"], + workspaceKeys: [], }; } -function workspace(input: Partial): WorkspaceDescriptor { - return { - id: input.id ?? "workspace-a", - projectId: input.projectId ?? "project-a", - projectDisplayName: input.projectDisplayName ?? "Project A", - projectRootPath: input.projectRootPath ?? "/repo/a", - workspaceDirectory: input.workspaceDirectory ?? "/repo/a", - projectKind: input.projectKind ?? "git", - workspaceKind: input.workspaceKind ?? "local_checkout", - name: input.name ?? "main", - status: input.status ?? "done", - statusEnteredAt: input.statusEnteredAt ?? null, - archivingAt: input.archivingAt ?? null, - diffStat: input.diffStat ?? null, - scripts: input.scripts ?? [], - }; -} - -const routeProject = hostProject({ - projectKey: "route-project", - projectName: "Route Project", - iconWorkingDir: "/repo/route", -}); -const lastActiveProject = hostProject({ - projectKey: "last-project", - projectName: "Last Project", - iconWorkingDir: "/repo/last", -}); -const firstProject = hostProject({ - projectKey: "first-project", - projectName: "First Project", - iconWorkingDir: "/repo/first", -}); - -describe("host project list", () => { - it("preserves workspace-structure order and project metadata", () => { - expect( - buildHostProjectList({ - projects: [ - structureProject({ - projectKey: "project-b", - projectName: "Project B", - projectKind: "directory", - iconWorkingDir: "/repo/b", - workspaceKeys: ["workspace-b"], - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }], - }), - structureProject({ - projectKey: "project-a", - projectName: "Project A", - projectKind: "git", - iconWorkingDir: "/repo/a", - workspaceKeys: ["workspace-a"], - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }], - }), - ], - }), - ).toEqual([ - { - projectKey: "project-b", - projectName: "Project B", - projectKind: "directory", - iconWorkingDir: "/repo/b", - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/b", canCreateWorktree: false }], - workspaceKeys: ["workspace-b"], - }, - { - projectKey: "project-a", - projectName: "Project A", - projectKind: "git", - iconWorkingDir: "/repo/a", - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }], - workspaceKeys: ["workspace-a"], - }, - ]); +describe("host project lookups", () => { + test("returns host-local ids and roots without falling back to the grouping key", () => { + expect(getHostProjectId(project(), "host-b")).toBe("prj_b"); + expect(getHostProjectSourceDirectory(project(), "host-b")).toBe("/repo/b"); + expect(getHostProjectId(project(), "missing")).toBeNull(); }); - it("keeps worktree capability separate from project listability", () => { - expect(canCreateWorktreeForProjectKind("git")).toBe(true); - expect(canCreateWorktreeForProjectKind("directory")).toBe(false); - }); - - it("uses route project before last active project when it can create worktrees", () => { + test("checks workspace creation against the selected host placement", () => { expect( - resolveInitialWorktreeProject({ - routeProject, - lastActiveProject, - projects: [firstProject], - }), - ).toEqual(routeProject); - }); - - it("skips non-worktree route and last-active projects", () => { - expect( - resolveInitialWorktreeProject({ - routeProject: { - ...routeProject, - projectKind: "directory", - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/route", canCreateWorktree: false }], - }, - lastActiveProject: { - ...lastActiveProject, - projectKind: "directory", - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/last", canCreateWorktree: false }], - }, - projects: [ - { - ...firstProject, - projectKind: "directory", - hosts: [ - { serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false }, - ], - }, - hostProject({ projectKey: "git-project", projectName: "Git Project" }), - ], - }), - ).toMatchObject({ projectKey: "git-project" }); - }); - - it("leaves the project empty when no worktree-capable project is available", () => { - expect( - resolveInitialWorktreeProject({ - routeProject: null, - lastActiveProject: null, - projects: [ - { - ...firstProject, - projectKind: "directory", - hosts: [ - { serverId: "host-a", iconWorkingDir: "/repo/first", canCreateWorktree: false }, - ], - }, - ], - }), - ).toBeNull(); - }); - - it("filters new-workspace projects to the selected host", () => { - const hostAOnly = hostProject({ - projectKey: "host-a-project", - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }], - }); - const hostBOnly = hostProject({ - projectKey: "host-b-project", - hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }], - }); - - expect( - filterWorkspaceProjectsForHost({ - projects: [hostAOnly, hostBOnly], + canCreateWorkspaceForHostProject({ + project: project(), serverId: "host-b", allowAllProjects: false, - }).map((project) => project.projectKey), - ).toEqual(["host-b-project"]); - }); - - it("allows directory projects only when workspace multiplicity is supported", () => { - const directoryProject = hostProject({ - projectKey: "directory-project", - projectKind: "directory", - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/directory", canCreateWorktree: false }], - }); - - expect( - canCreateWorkspaceForHostProject({ - project: directoryProject, - serverId: "host-a", - allowAllProjects: false, - }), - ).toBe(false); - expect( - canCreateWorkspaceForHostProject({ - project: directoryProject, - serverId: "host-a", - allowAllProjects: true, }), ).toBe(true); }); - it("falls back when the route project is not available on the selected host", () => { - const selectedHostProject = hostProject({ - projectKey: "selected-host-project", - hosts: [ - { serverId: "host-b", iconWorkingDir: "/repo/selected-host", canCreateWorktree: true }, - ], - }); - - expect( - resolveInitialWorkspaceProject({ - routeProject, - lastActiveProject: null, - projects: [selectedHostProject], - serverId: "host-b", - allowAllProjects: false, - }), - ).toEqual(selectedHostProject); - }); - - it("resolves the selected host project source directory", () => { - const project = hostProject({ - hosts: [ - { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }, - { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, - ], - }); - - expect(getHostProjectSourceDirectory(project, "host-b")).toBe("/repo/b"); - expect(getHostProjectSourceDirectory(project, "host-c")).toBeNull(); - }); - - it("keeps a selected route project available before project hydration", () => { - expect( - resolveSelectedHostProject({ - selectedProjectKey: routeProject.projectKey, - projects: [], - routeProject, - lastActiveProject: null, - }), - ).toEqual(routeProject); - }); - - it("converts route project only when it has a key and source directory", () => { + test("builds an unhydrated route project around the routed project id", () => { expect( hostProjectFromRoute({ serverId: "host-a", - projectId: "project-a", - displayName: "Project A", + projectId: "prj_a", + displayName: "App", sourceDirectory: "/repo/a", }), - ).toEqual({ - projectKey: "project-a", - projectName: "Project A", - projectKind: "git", - iconWorkingDir: "/repo/a", - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }], - workspaceKeys: [], - }); - expect(hostProjectFromRoute({ serverId: "host-a", projectId: "project-a" })).toBeNull(); - }); - - it("converts last active workspaces with matching worktree capability", () => { - expect(hostProjectFromWorkspace({ serverId: "host-a", workspace: workspace({}) })).toEqual({ - projectKey: "project-a", - projectName: "Project A", - projectKind: "git", - iconWorkingDir: "/repo/a", - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }], - workspaceKeys: ["host-a:workspace-a"], - }); - - expect( - hostProjectFromWorkspace({ - serverId: "host-a", - workspace: workspace({ projectKind: "directory" }), - }), ).toMatchObject({ - projectKind: "directory", - hosts: [{ serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }], + projectKey: "prj_a", + hosts: [{ serverId: "host-a", projectId: "prj_a" }], }); }); }); diff --git a/packages/app/src/projects/host-projects.ts b/packages/app/src/projects/host-projects.ts index 15d086bf65..fb12ebe7df 100644 --- a/packages/app/src/projects/host-projects.ts +++ b/packages/app/src/projects/host-projects.ts @@ -1,13 +1,12 @@ -import { useMemo } from "react"; import { useWorkspaceStructure } from "@/stores/session-store-hooks"; -import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model"; +import { type HostProjectListItem } from "@/projects/host-project-model"; export { - buildHostProjectList, canCreateWorkspaceForHostProject, canCreateWorktreeForProjectKind, filterWorkspaceProjectsForHost, getHostProjectSourceDirectory, + getHostProjectId, hostProjectFromRoute, hostProjectFromWorkspace, resolveInitialWorkspaceProject, @@ -19,10 +18,5 @@ export { export function useHostProjects(serverIds: string[]): HostProjectListItem[] { const workspaceStructure = useWorkspaceStructure(serverIds); - return useMemo(() => { - if (workspaceStructure.projects.length === 0) { - return []; - } - return buildHostProjectList({ projects: workspaceStructure.projects }); - }, [workspaceStructure.projects]); + return workspaceStructure.projects; } diff --git a/packages/app/src/projects/project-remove.test.ts b/packages/app/src/projects/project-remove.test.ts index 48fc7b9414..3a458a42a3 100644 --- a/packages/app/src/projects/project-remove.test.ts +++ b/packages/app/src/projects/project-remove.test.ts @@ -7,7 +7,10 @@ import { const project: ProjectRemoveProject = { projectKey: "remote:github.com/acme/app", - hosts: [{ serverId: "host-a" }, { serverId: "host-b" }], + hosts: [ + { serverId: "host-a", projectId: "prj_host_a" }, + { serverId: "host-b", projectId: "prj_host_b" }, + ], }; function createProjectRemoveClient() { @@ -46,12 +49,14 @@ describe("project remove policy", () => { expect(readiness).toEqual({ kind: "ready", - targets: [{ serverId: "host-a" }, { serverId: "host-b" }], + targets: [ + { serverId: "host-a", projectId: "prj_host_a" }, + { serverId: "host-b", projectId: "prj_host_b" }, + ], }); const outcome = await removeProjectFromHosts({ - projectKey: project.projectKey, - targets: [{ serverId: "host-a" }, { serverId: "host-b" }], + targets: readiness.kind === "ready" ? readiness.targets : [], getClient: (serverId) => { if (serverId === "host-a") return hostA.client; if (serverId === "host-b") return hostB.client; @@ -60,16 +65,18 @@ describe("project remove policy", () => { }); expect(outcome).toEqual({ kind: "removed", serverIds: ["host-a", "host-b"] }); - expect(hostA.removedProjectKeys).toEqual([project.projectKey]); - expect(hostB.removedProjectKeys).toEqual([project.projectKey]); + expect(hostA.removedProjectKeys).toEqual(["prj_host_a"]); + expect(hostB.removedProjectKeys).toEqual(["prj_host_b"]); }); it("reports disconnected hosts before sending any remove request", async () => { const hostA = createProjectRemoveClient(); const outcome = await removeProjectFromHosts({ - projectKey: project.projectKey, - targets: [{ serverId: "host-a" }, { serverId: "host-b" }], + targets: [ + { serverId: "host-a", projectId: "prj_host_a" }, + { serverId: "host-b", projectId: "prj_host_b" }, + ], getClient: (serverId) => (serverId === "host-a" ? hostA.client : null), }); diff --git a/packages/app/src/projects/project-remove.ts b/packages/app/src/projects/project-remove.ts index 1f9529a56d..330ea09238 100644 --- a/packages/app/src/projects/project-remove.ts +++ b/packages/app/src/projects/project-remove.ts @@ -4,6 +4,7 @@ import { selectHostFeature } from "@/runtime/host-features"; interface ProjectRemoveHost { serverId: string; + projectId: string; } export interface ProjectRemoveProject { @@ -13,6 +14,7 @@ export interface ProjectRemoveProject { export interface ProjectRemoveTarget { serverId: string; + projectId: string; } export type ProjectRemoveReadiness = @@ -38,7 +40,10 @@ export function getProjectRemoveReadiness(input: { unsupportedServerIds.push(host.serverId); continue; } - targets.push({ serverId: host.serverId }); + targets.push({ + serverId: host.serverId, + projectId: host.projectId, + }); } if (unsupportedServerIds.length > 0) { @@ -59,11 +64,10 @@ export function getCurrentProjectRemoveReadiness( } export async function removeProjectFromHosts(input: { - projectKey: string; targets: readonly ProjectRemoveTarget[]; getClient: (serverId: string) => ProjectRemoveClient | null; }): Promise { - const clients: Array<{ serverId: string; client: ProjectRemoveClient }> = []; + const clients: Array<{ serverId: string; projectId: string; client: ProjectRemoveClient }> = []; const disconnectedServerIds: string[] = []; for (const target of input.targets) { @@ -72,7 +76,7 @@ export async function removeProjectFromHosts(input: { disconnectedServerIds.push(target.serverId); continue; } - clients.push({ serverId: target.serverId, client }); + clients.push({ serverId: target.serverId, projectId: target.projectId, client }); } if (disconnectedServerIds.length > 0) { @@ -80,8 +84,8 @@ export async function removeProjectFromHosts(input: { } const results = await Promise.allSettled( - clients.map(async ({ client }) => { - await client.removeProject(input.projectKey); + clients.map(async ({ client, projectId }) => { + await client.removeProject(projectId); }), ); const failedServerIds: string[] = []; diff --git a/packages/app/src/projects/workspace-fetching.ts b/packages/app/src/projects/workspace-fetching.ts deleted file mode 100644 index 8a10157515..0000000000 --- a/packages/app/src/projects/workspace-fetching.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { - type EmptyProjectDescriptor, - normalizeEmptyProjectDescriptor, - normalizeWorkspaceDescriptor, - type WorkspaceDescriptor, -} from "@/stores/session-store"; - -export type FetchWorkspacesClient = Pick; -export type FetchWorkspacesSort = NonNullable< - Parameters[0] ->["sort"]; - -export interface FetchWorkspaceDescriptorsResult { - workspaces: WorkspaceDescriptor[]; - /** - * Project parents with no active workspaces. The daemon only rides these on - * the first page of `fetchWorkspaces`, so a freshly-added project that has no - * workspace yet shows up here and nowhere else — drop it and the project is - * invisible to anything that derives projects from workspaces alone. - */ - emptyProjects: EmptyProjectDescriptor[]; -} - -export async function fetchAllWorkspaceDescriptors(input: { - client: FetchWorkspacesClient; - sort: FetchWorkspacesSort; -}): Promise { - const workspaces: WorkspaceDescriptor[] = []; - const emptyProjects = new Map(); - let cursor: string | null = null; - - while (true) { - const payload = await input.client.fetchWorkspaces({ - sort: input.sort, - page: cursor ? { limit: 200, cursor } : { limit: 200 }, - }); - workspaces.push(...payload.entries.map((entry) => normalizeWorkspaceDescriptor(entry))); - for (const project of payload.emptyProjects ?? []) { - const descriptor = normalizeEmptyProjectDescriptor(project); - emptyProjects.set(descriptor.projectId, descriptor); - } - if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { - break; - } - cursor = payload.pageInfo.nextCursor; - } - - return { workspaces, emptyProjects: Array.from(emptyProjects.values()) }; -} diff --git a/packages/app/src/projects/workspace-structure.test.ts b/packages/app/src/projects/workspace-structure.test.ts new file mode 100644 index 0000000000..7b621198e2 --- /dev/null +++ b/packages/app/src/projects/workspace-structure.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "vitest"; +import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; +import { buildWorkspaceStructureProjects } from "./workspace-structure"; + +function project(input: { + id: string; + key: string | null; + root: string; + name?: string; +}): ProjectDescriptor { + return { + projectId: input.id, + projectKey: input.key, + projectDisplayName: input.name ?? "acme/app", + projectCustomName: null, + projectRootPath: input.root, + projectKind: "git", + }; +} + +function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor { + return { + id, + projectId, + projectDisplayName: "acme/app", + projectCustomName: null, + projectRootPath: root, + workspaceDirectory: root, + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + statusEnteredAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +describe("buildWorkspaceStructureProjects", () => { + test("groups the same project key across hosts and keeps host-local ids", () => { + const key = "remote:github.com/acme/app"; + const result = buildWorkspaceStructureProjects({ + sessions: [ + { + serverId: "host-a", + projects: [project({ id: "prj_a", key, root: "/a/app" })], + workspaces: [workspace("ws-a", "prj_a", "/a/app")], + }, + { + serverId: "host-b", + projects: [project({ id: "prj_b", key, root: "/b/app" })], + workspaces: [workspace("ws-b", "prj_b", "/b/app")], + }, + ], + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + projectKey: key, + hosts: [ + { serverId: "host-a", projectId: "prj_a" }, + { serverId: "host-b", projectId: "prj_b" }, + ], + workspaceKeys: ["host-a:ws-a", "host-b:ws-b"], + }); + }); + + test("keeps two clones with the same key on one host separate", () => { + const key = "remote:github.com/acme/app"; + const result = buildWorkspaceStructureProjects({ + sessions: [ + { + serverId: "host-a", + projects: [ + project({ id: "prj_one", key, root: "/repos/one" }), + project({ id: "prj_two", key, root: "/repos/two" }), + ], + workspaces: [ + workspace("ws-one", "prj_one", "/repos/one"), + workspace("ws-two", "prj_two", "/repos/two"), + ], + }, + ], + }); + + expect(result).toHaveLength(2); + expect(result.map((item) => item.hosts[0]?.projectId).sort()).toEqual(["prj_one", "prj_two"]); + expect(result.map((item) => item.workspaceKeys[0]).sort()).toEqual([ + "host-a:ws-one", + "host-a:ws-two", + ]); + }); + + test("does not let project order choose which same-host clone groups with another host", () => { + const key = "remote:github.com/acme/app"; + const hostAProjects = [ + project({ id: "prj_one", key, root: "/repos/one" }), + project({ id: "prj_two", key, root: "/repos/two" }), + ]; + const build = (projects: ProjectDescriptor[]) => + buildWorkspaceStructureProjects({ + sessions: [ + { serverId: "host-a", projects, workspaces: [] }, + { + serverId: "host-b", + projects: [project({ id: "prj_remote", key, root: "/repos/remote" })], + workspaces: [], + }, + ], + }); + const identitiesByProjectId = (projects: ReturnType) => { + const identities: Record = {}; + for (const group of projects) { + for (const host of group.hosts) identities[host.projectId] = group.projectKey; + } + return identities; + }; + + const forward = build(hostAProjects); + const reversed = build(hostAProjects.toReversed()); + + expect(forward).toHaveLength(3); + expect(identitiesByProjectId(forward)).toEqual(identitiesByProjectId(reversed)); + expect(new Set(Object.values(identitiesByProjectId(forward))).size).toBe(3); + }); + + test("keeps projects without persisted keys scoped to their host", () => { + const result = buildWorkspaceStructureProjects({ + sessions: [ + { + serverId: "host-a", + projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })], + workspaces: [], + }, + { + serverId: "host-b", + projects: [project({ id: "/workspace/app", key: null, root: "/workspace/app" })], + workspaces: [], + }, + ], + }); + + expect(result).toHaveLength(2); + expect(new Set(result.map((item) => item.projectKey)).size).toBe(2); + }); +}); diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index f410656aba..9fc635063b 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -1,8 +1,9 @@ -import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; +import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; export interface WorkspaceStructureHostPlacement { serverId: string; + projectId: string; iconWorkingDir: string; canCreateWorktree: boolean; } @@ -20,126 +21,55 @@ export interface WorkspaceStructure { projects: WorkspaceStructureProject[]; } -function compareWorkspaceStructureItems( - left: { workspaceId: string; workspaceName: string }, - right: { workspaceId: string; workspaceName: string }, -): number { - const nameDelta = left.workspaceName.localeCompare(right.workspaceName, undefined, { - numeric: true, - sensitivity: "base", - }); - if (nameDelta !== 0) { - return nameDelta; - } - - return left.workspaceId.localeCompare(right.workspaceId, undefined, { - sensitivity: "base", - }); -} - -function compareWorkspaceStructureProjects( - left: WorkspaceStructureProject, - right: WorkspaceStructureProject, -): number { - return left.projectName.localeCompare(right.projectName, undefined, { - numeric: true, - sensitivity: "base", - }); -} - -function canCreateWorktreeForProjectKind(projectKind: WorkspaceDescriptor["projectKind"]): boolean { - return projectKind === "git"; -} - interface WorkspaceStructureSession { serverId: string; + projects: Iterable; workspaces: Iterable; - emptyProjects?: Iterable; } +interface ProjectDraft { + projectKey: string; + projectName: string; + hasCustomName: boolean; + projectKind: WorkspaceDescriptor["projectKind"]; + iconWorkingDir: string; + hosts: Map; + workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>; +} + +/** The single app boundary that turns host-local projects into grouped display projects. */ export function buildWorkspaceStructureProjects(input: { sessions: WorkspaceStructureSession[]; }): WorkspaceStructureProject[] { - const byProject = new Map< - string, - { - projectKey: string; - projectName: string; - projectKind: WorkspaceDescriptor["projectKind"]; - iconWorkingDir: string; - hosts: Map; - workspaces: Array<{ workspaceId: string; workspaceName: string; workspaceKey: string }>; - } - >(); + const byProject = new Map(); + const projectEntries: Array<{ serverId: string; project: ProjectDescriptor }> = []; + const keyCountsByServer = new Map>(); + const viewKeyByServerProjectId = new Map>(); for (const session of input.sessions) { - for (const emptyProject of session.emptyProjects ?? []) { - const projectKey = emptyProject.projectId; - const placement = { - serverId: session.serverId, - iconWorkingDir: emptyProject.projectRootPath, - canCreateWorktree: canCreateWorktreeForProjectKind(emptyProject.projectKind), - }; - const existing = byProject.get(projectKey); - - if (!existing) { - byProject.set(projectKey, { - projectKey, - projectName: - emptyProject.projectCustomName ?? - emptyProject.projectDisplayName ?? - projectDisplayNameFromProjectId(projectKey), - projectKind: emptyProject.projectKind, - iconWorkingDir: emptyProject.projectRootPath, - hosts: new Map([[session.serverId, placement]]), - workspaces: [], - }); - continue; + for (const project of session.projects) { + projectEntries.push({ serverId: session.serverId, project }); + const sharedKey = project.projectKey ?? null; + if (sharedKey) { + const counts = getOrCreate(keyCountsByServer, session.serverId, () => new Map()); + counts.set(sharedKey, (counts.get(sharedKey) ?? 0) + 1); } - - existing.hosts.set(session.serverId, placement); } + } + for (const { serverId, project } of projectEntries) { + const projectKey = addProjectToView({ byProject, keyCountsByServer, serverId, project }); + getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set( + project.projectId, + projectKey, + ); + } + + for (const session of input.sessions) { for (const workspace of session.workspaces) { - const projectKey = workspace.project?.projectKey ?? workspace.projectId; - const existing = byProject.get(projectKey); - - if (!existing) { - byProject.set(projectKey, { - projectKey, - projectName: - workspace.projectCustomName ?? - workspace.projectDisplayName ?? - projectDisplayNameFromProjectId(projectKey), - projectKind: workspace.projectKind, - iconWorkingDir: workspace.projectRootPath, - hosts: new Map([ - [ - session.serverId, - { - serverId: session.serverId, - iconWorkingDir: workspace.projectRootPath, - canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind), - }, - ], - ]), - workspaces: [ - { - workspaceId: workspace.id, - workspaceName: workspace.name, - workspaceKey: `${session.serverId}:${workspace.id}`, - }, - ], - }); - continue; - } - - existing.hosts.set(session.serverId, { - serverId: session.serverId, - iconWorkingDir: workspace.projectRootPath, - canCreateWorktree: canCreateWorktreeForProjectKind(workspace.projectKind), - }); - existing.workspaces.push({ + const projectKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); + if (!projectKey) continue; + byProject.get(projectKey)?.workspaces.push({ workspaceId: workspace.id, workspaceName: workspace.name, workspaceKey: `${session.serverId}:${workspace.id}`, @@ -147,19 +77,82 @@ export function buildWorkspaceStructureProjects(input: { } } - const projects: WorkspaceStructureProject[] = []; - for (const raw of byProject.values()) { - const sortedWorkspaces = [...raw.workspaces].sort(compareWorkspaceStructureItems); - projects.push({ - projectKey: raw.projectKey, - projectName: raw.projectName, - projectKind: raw.projectKind, - iconWorkingDir: raw.iconWorkingDir, - hosts: Array.from(raw.hosts.values()), - workspaceKeys: sortedWorkspaces.map((w) => w.workspaceKey), + return Array.from(byProject.values()) + .map((draft) => ({ + projectKey: draft.projectKey, + projectName: draft.projectName, + projectKind: draft.projectKind, + iconWorkingDir: draft.iconWorkingDir, + hosts: Array.from(draft.hosts.values()), + workspaceKeys: draft.workspaces + .sort(compareWorkspaceStructureItems) + .map((workspace) => workspace.workspaceKey), + })) + .sort((left, right) => + left.projectName.localeCompare(right.projectName, undefined, { + numeric: true, + sensitivity: "base", + }), + ); +} + +function addProjectToView(input: { + byProject: Map; + keyCountsByServer: Map>; + serverId: string; + project: ProjectDescriptor; +}): string { + const { byProject, keyCountsByServer, serverId, project } = input; + const sharedKey = project.projectKey ?? null; + const canUseSharedKey = + sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1; + const projectKey = canUseSharedKey ? sharedKey : JSON.stringify([serverId, project.projectId]); + const placement: WorkspaceStructureHostPlacement = { + serverId, + projectId: project.projectId, + iconWorkingDir: project.projectRootPath, + canCreateWorktree: project.projectKind === "git", + }; + const draft = byProject.get(projectKey); + if (!draft) { + byProject.set(projectKey, { + projectKey, + projectName: + project.projectCustomName ?? + project.projectDisplayName ?? + projectDisplayNameFromProjectId(project.projectId), + hasCustomName: Boolean(project.projectCustomName), + projectKind: project.projectKind, + iconWorkingDir: project.projectRootPath, + hosts: new Map([[serverId, placement]]), + workspaces: [], }); + } else { + if (project.projectCustomName && !draft.hasCustomName) { + draft.projectName = project.projectCustomName; + draft.hasCustomName = true; + } + draft.hosts.set(serverId, placement); } + return projectKey; +} + +function getOrCreate(map: Map, key: K, create: () => V): V { + const existing = map.get(key); + if (existing !== undefined) return existing; + const value = create(); + map.set(key, value); + return value; +} - projects.sort(compareWorkspaceStructureProjects); - return projects; +function compareWorkspaceStructureItems( + left: { workspaceId: string; workspaceName: string }, + right: { workspaceId: string; workspaceName: string }, +): number { + return ( + left.workspaceName.localeCompare(right.workspaceName, undefined, { + numeric: true, + sensitivity: "base", + }) || left.workspaceId.localeCompare(right.workspaceId, undefined, { sensitivity: "base" }) + ); } diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 7279677139..94eabb0c80 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -5,10 +5,12 @@ import { useSessionStore } from "@/stores/session-store"; import { DirectoryRefreshSupersededError, DirectorySync } from "./index"; type WorkspaceFetchResult = Awaited>; +type ProjectListResult = Awaited>; class FakeDirectoryClient { fetchAgentsCalls = 0; fetchWorkspacesCalls = 0; + listProjectsCalls = 0; private pendingWorkspaceFetch: Promise | null = null; private readonly handlers = new Map< SessionOutboundMessage["type"], @@ -63,6 +65,22 @@ class FakeDirectoryClient { pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, }; } + + async listProjects(): Promise { + this.listProjectsCalls += 1; + return { + requestId: "projects", + projects: [ + { + projectId: "project-1", + projectKey: "remote:github.com/acme/app", + projectDisplayName: "acme/app", + projectRootPath: "/repo/app", + projectKind: "git", + }, + ], + }; + } } const serverIds = new Set(); @@ -119,6 +137,28 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it("fetches the project descriptor channel when the daemon advertises it", async () => { + const serverId = "project-list"; + const { client, directory } = createDirectory(serverId); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, projectList: true }, + }); + + await directory.refreshWorkspaces(); + + expect(client.listProjectsCalls).toBe(1); + expect(useSessionStore.getState().sessions[serverId]?.projects.get("project-1")).toMatchObject({ + projectId: "project-1", + projectKey: "remote:github.com/acme/app", + }); + directory.dispose(); + }); + it("rejects a session wait on disconnect so the reconnect can refresh", async () => { const serverId = "session-wait-reconnect"; const { client, directory } = createDirectory(serverId); @@ -200,17 +240,14 @@ describe("DirectorySync session readiness", () => { }); await refresh; - const emptyProjects = useSessionStore.getState().sessions[serverId]?.emptyProjects; - expect(Array.from(emptyProjects?.keys() ?? [])).toEqual([ - "snapshot-project", - "workspace-project", - ]); - expect(emptyProjects?.get("snapshot-project")).toMatchObject({ + const projects = useSessionStore.getState().sessions[serverId]?.projects; + expect(Array.from(projects?.keys() ?? [])).toEqual(["snapshot-project", "workspace-project"]); + expect(projects?.get("snapshot-project")).toMatchObject({ projectDisplayName: "Renamed during hydration", projectRootPath: "/moved/snapshot-project", projectKind: "directory", }); - expect(emptyProjects?.get("workspace-project")).toMatchObject({ + expect(projects?.get("workspace-project")).toMatchObject({ projectDisplayName: "Project from workspace update", }); directory.dispose(); @@ -247,7 +284,7 @@ describe("DirectorySync session readiness", () => { expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true); expect( - useSessionStore.getState().sessions[serverId]?.emptyProjects.get("early-project"), + useSessionStore.getState().sessions[serverId]?.projects.get("early-project"), ).toMatchObject({ projectDisplayName: "Early project", projectRootPath: "/repo/early-project", diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 317eabf6af..734b89a3c8 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -5,10 +5,12 @@ import type { } from "@getpaseo/client/internal/daemon-client"; import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once"; import { - normalizeEmptyProjectDescriptor, + normalizeProjectDescriptor, normalizeWorkspaceDescriptor, useSessionStore, type Agent, + type ProjectDescriptor, + type WorkspaceDescriptor, } from "@/stores/session-store"; import { readLegacyDaemonWorkspaceDirectory, @@ -109,7 +111,7 @@ export class DirectorySync { const source = connection.source; this.workspaceTransactions.begin(source, () => ({ workspaces: new Map(), - emptyProjects: new Map(), + projects: new Map(), })); const subscriptions = [ client.on("agent_update", (message) => { @@ -205,8 +207,12 @@ export class DirectorySync { } if (completion.snapshot.legacy) { const store = useSessionStore.getState(); - store.setWorkspaces(this.serverId, buildLegacyWorkspaces(completion.snapshot.entries)); - store.setEmptyProjects(this.serverId, []); + const workspaces = buildLegacyWorkspaces(completion.snapshot.entries); + store.setWorkspaces(this.serverId, workspaces); + store.setProjects( + this.serverId, + Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), + ); store.setHasHydratedWorkspaces(this.serverId, true); } const deltas = completion.snapshot.legacy @@ -233,7 +239,7 @@ export class DirectorySync { const { client, source } = this.requireOnline(); const transaction = this.workspaceTransactions.begin(source, () => ({ workspaces: new Map(), - emptyProjects: new Map(), + projects: new Map(), })); try { await this.waitForSessionMetadata(client, source); @@ -243,7 +249,17 @@ export class DirectorySync { if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); return; } + const supportsProjectList = serverInfo.features?.projectList === true; + if (supportsProjectList) { + const payload = await client.listProjects(); + this.assertWorkspaceTransactionCurrent(client, source, transaction); + for (const entry of payload.projects) { + const project = normalizeProjectDescriptor(entry); + transaction.snapshot.projects.set(project.projectId, project); + } + } await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true); + if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot); if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { throw new DirectoryRefreshSupersededError("workspace completion no longer current"); } @@ -279,8 +295,8 @@ export class DirectorySync { transaction.snapshot.workspaces.set(workspace.id, workspace); } for (const entry of payload.emptyProjects ?? []) { - const project = normalizeEmptyProjectDescriptor(entry); - transaction.snapshot.emptyProjects.set(project.projectId, project); + const project = normalizeProjectDescriptor(entry); + transaction.snapshot.projects.set(project.projectId, project); } if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; cursor = payload.pageInfo.nextCursor; @@ -288,6 +304,15 @@ export class DirectorySync { } } + private buildLegacyProjectSnapshot(snapshot: WorkspaceDirectorySnapshot): void { + for (const workspace of snapshot.workspaces.values()) { + if (!snapshot.projects.has(workspace.projectId)) { + const project = legacyProjectDescriptorFromWorkspace(workspace); + snapshot.projects.set(project.projectId, project); + } + } + } + async refreshAll(): Promise { await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]); } @@ -421,6 +446,17 @@ export class DirectorySync { } } +function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { + return { + projectId: workspace.projectId, + projectKey: null, + projectDisplayName: workspace.projectDisplayName, + projectCustomName: workspace.projectCustomName ?? null, + projectRootPath: workspace.projectRootPath, + projectKind: workspace.projectKind, + }; +} + export class DirectoryRefreshSupersededError extends Error {} export type { DirectorySourceToken } from "./transaction"; diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index f72b781027..9a1705bd53 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { - normalizeEmptyProjectDescriptor, + normalizeProjectDescriptor, normalizeWorkspaceDescriptor, useSessionStore, } from "@/stores/session-store"; @@ -33,7 +33,7 @@ it("commits workspace and project-parent state with filtered removals", () => { const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); const replica = new WorkspaceDirectoryReplica(serverId); - const empty = normalizeEmptyProjectDescriptor({ + const empty = normalizeProjectDescriptor({ projectId: "empty", projectDisplayName: "Empty", projectRootPath: "/repo/empty", @@ -45,14 +45,14 @@ it("commits workspace and project-parent state with filtered removals", () => { ["kept", normalizeWorkspaceDescriptor(workspace("kept"))], ["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))], ]), - emptyProjects: new Map([[empty.projectId, empty]]), + projects: new Map([[empty.projectId, empty]]), }, [{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }], ); const session = useSessionStore.getState().sessions[serverId]; expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]); - expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]); + expect(Array.from(session?.projects.keys() ?? [])).toEqual(["empty"]); store.clearSession(serverId); }); @@ -65,19 +65,19 @@ it("commits the authoritative snapshot before buffered project updates", () => { const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached")); const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed")); const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated")); - const staleAttachedProject = normalizeEmptyProjectDescriptor({ + const staleAttachedProject = normalizeProjectDescriptor({ projectId: "attached", projectDisplayName: "Stale attached project", projectRootPath: "/repo/attached", projectKind: "git", }); - const removedProject = normalizeEmptyProjectDescriptor({ + const removedProject = normalizeProjectDescriptor({ projectId: "removed", projectDisplayName: "Removed project", projectRootPath: "/repo/removed", projectKind: "git", }); - const unchangedEmptyProject = normalizeEmptyProjectDescriptor({ + const unchangedEmptyProject = normalizeProjectDescriptor({ projectId: "unchanged-empty", projectDisplayName: "Unchanged empty project", projectRootPath: "/repo/unchanged-empty", @@ -92,7 +92,7 @@ it("commits the authoritative snapshot before buffered project updates", () => { [removed.id, removed], [unrelated.id, unrelated], ]), - emptyProjects: new Map([ + projects: new Map([ [staleAttachedProject.projectId, staleAttachedProject], [removedProject.projectId, removedProject], [unchangedEmptyProject.projectId, unchangedEmptyProject], @@ -103,6 +103,7 @@ it("commits the authoritative snapshot before buffered project updates", () => { kind: "upsert", project: { projectId: "attached", + projectKey: "remote:github.com/acme/attached", projectDisplayName: "Renamed attached project", projectCustomName: "Personal name", projectRootPath: "/moved/attached", @@ -135,11 +136,29 @@ it("commits the authoritative snapshot before buffered project updates", () => { }); expect(session?.workspaces.has(removed.id)).toBe(false); expect(session?.workspaces.get(unrelated.id)).toBe(unrelated); - expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["unchanged-empty", "new-empty"]); - expect(session?.emptyProjects.get("unchanged-empty")).toBe(unchangedEmptyProject); - expect(session?.emptyProjects.get("new-empty")).toMatchObject({ + expect(Array.from(session?.projects.keys() ?? [])).toEqual([ + "attached", + "unchanged-empty", + "new-empty", + ]); + expect(session?.projects.get("unchanged-empty")).toBe(unchangedEmptyProject); + expect(session?.projects.get("new-empty")).toMatchObject({ projectDisplayName: "New empty project", projectRootPath: "/repo/new-empty", }); store.clearSession(serverId); }); + +it("does not invent a null-key project from a workspace update", () => { + const serverId = "workspace-before-project-update"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + + replica.applyDelta({ kind: "upsert", workspace: workspace("main", "fresh-project") }); + + const session = useSessionStore.getState().sessions[serverId]; + expect(session?.workspaces.has("main")).toBe(true); + expect(session?.projects.has("fresh-project")).toBe(false); + store.clearSession(serverId); +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index e78a2b557e..38852a08e9 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -1,9 +1,9 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { - normalizeEmptyProjectDescriptor, + normalizeProjectDescriptor, normalizeWorkspaceDescriptor, useSessionStore, - type EmptyProjectDescriptor, + type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; @@ -20,7 +20,7 @@ type ProjectDirectoryDelta = Extract; - emptyProjects: Map; + projects: Map; } function applyProjectDelta( @@ -28,18 +28,17 @@ function applyProjectDelta( delta: ProjectDirectoryDelta, ): void { if (delta.kind === "remove") { - snapshot.emptyProjects.delete(delta.projectId); + snapshot.projects.delete(delta.projectId); for (const [workspaceId, workspace] of snapshot.workspaces) { if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId); } return; } - const project = normalizeEmptyProjectDescriptor(delta.project); - let hasAttachedWorkspace = false; + const project = normalizeProjectDescriptor(delta.project); + snapshot.projects.set(project.projectId, project); for (const [workspaceId, workspace] of snapshot.workspaces) { if (workspace.projectId !== project.projectId) continue; - hasAttachedWorkspace = true; snapshot.workspaces.set(workspaceId, { ...workspace, projectDisplayName: project.projectDisplayName, @@ -48,8 +47,6 @@ function applyProjectDelta( projectKind: project.projectKind, }); } - if (hasAttachedWorkspace) snapshot.emptyProjects.delete(project.projectId); - else snapshot.emptyProjects.set(project.projectId, project); } export class WorkspaceDirectoryReplica { @@ -75,7 +72,7 @@ export class WorkspaceDirectoryReplica { const session = useSessionStore.getState().sessions[this.serverId]; return { workspaces: new Map(session?.workspaces), - emptyProjects: new Map(session?.emptyProjects), + projects: new Map(session?.projects), }; } @@ -84,7 +81,7 @@ export class WorkspaceDirectoryReplica { deltas: readonly WorkspaceDirectoryDelta[], ): WorkspaceDirectorySnapshot { const workspaces = new Map(snapshot.workspaces); - const emptyProjects = new Map(snapshot.emptyProjects); + const projects = new Map(snapshot.projects); for (const [workspaceId, workspace] of workspaces) { if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { workspaces.delete(workspaceId); @@ -92,16 +89,18 @@ export class WorkspaceDirectoryReplica { } for (const delta of deltas) { if ("projectId" in delta || "project" in delta) { - applyProjectDelta({ workspaces, emptyProjects }, delta); + applyProjectDelta({ workspaces, projects }, delta); continue; } if (delta.kind === "remove") { workspaces.delete(delta.id); if (delta.emptyProject) { - const project = normalizeEmptyProjectDescriptor(delta.emptyProject); - emptyProjects.set(project.projectId, project); + const project = normalizeProjectDescriptor(delta.emptyProject); + projects.set(project.projectId, project); + } + if (delta.removedProjectId) { + projects.delete(delta.removedProjectId); } - if (delta.removedProjectId) emptyProjects.delete(delta.removedProjectId); continue; } const workspace = normalizeWorkspaceDescriptor(delta.workspace); @@ -109,16 +108,15 @@ export class WorkspaceDirectoryReplica { workspaces.delete(workspace.id); } else { workspaces.set(workspace.id, workspace); - emptyProjects.delete(workspace.projectId); } } - return { workspaces, emptyProjects }; + return { workspaces, projects }; } private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void { const store = useSessionStore.getState(); store.setWorkspaces(this.serverId, snapshot.workspaces); - store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values()); + store.setProjects(this.serverId, snapshot.projects.values()); for (const workspaceId of removedWorkspaceIds) { clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId }); useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId }); diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 69800f03ac..bf3344de0e 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { - normalizeEmptyProjectDescriptor, + normalizeProjectDescriptor, normalizeWorkspaceDescriptor, useSessionStore, } from "@/stores/session-store"; @@ -96,8 +96,15 @@ function seedSession(): void { SERVER_ID, new Map([["workspace-1", normalizeWorkspaceDescriptor(workspace())]]), ); - store.setEmptyProjects(SERVER_ID, [ - normalizeEmptyProjectDescriptor({ + store.setProjects(SERVER_ID, [ + normalizeProjectDescriptor({ + projectId: "project-1", + projectKey: "remote:github.com/getpaseo/paseo", + projectDisplayName: "Paseo", + projectRootPath: "/repo/paseo", + projectKind: "git", + }), + normalizeProjectDescriptor({ projectId: "empty-project", projectDisplayName: "Empty project", projectRootPath: "/repo/empty", @@ -162,7 +169,7 @@ describe("ReplicaCache", () => { expect(session?.hasHydratedWorkspaces).toBe(false); expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1"]); expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1"]); - expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]); + expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-1"]); expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date); expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); @@ -213,7 +220,7 @@ describe("ReplicaCache", () => { const timelines = session?.agentStreamTail; expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]); expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]); - expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual([]); + expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]); expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]); expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50)); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 7f8582884e..66c67a0a7b 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -9,12 +9,13 @@ import { type WorkspaceDescriptorPayload, } from "@getpaseo/protocol/messages"; import { - normalizeEmptyProjectDescriptor, + normalizeProjectDescriptor, normalizeWorkspaceDescriptor, useSessionStore, type Agent, type SessionReplica, type SessionState, + type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; @@ -50,6 +51,7 @@ const StoredHostSchema = z.object({ serverId: z.string(), agents: z.array(StoredAgentSchema), workspaces: z.array(WorkspaceDescriptorPayloadSchema), + projects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]), emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema), timeline: StoredTimelineSchema.nullable(), }); @@ -214,18 +216,47 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor }; } +function serializeProject(project: ProjectDescriptor) { + return { + projectId: project.projectId, + ...(project.projectKey ? { projectKey: project.projectKey } : {}), + projectDisplayName: project.projectDisplayName, + projectCustomName: project.projectCustomName, + projectRootPath: project.projectRootPath, + projectKind: project.projectKind, + }; +} + function deserializeHost(stored: StoredHost): SessionReplica { const agents = stored.agents.map((entry) => deserializeAgent(stored.serverId, entry)); const workspaces = stored.workspaces.map(normalizeWorkspaceDescriptor); - const emptyProjects = stored.emptyProjects.map(normalizeEmptyProjectDescriptor); + const listedProjects = stored.projects.map(normalizeProjectDescriptor); + const legacyProjects = [ + ...stored.emptyProjects.map(normalizeProjectDescriptor), + ...workspaces.map(legacyProjectDescriptorFromWorkspace), + ]; + const projects = new Map( + [...legacyProjects, ...listedProjects].map((project) => [project.projectId, project]), + ); return { agents: new Map(agents.map((agent) => [agent.id, agent])), workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])), - emptyProjects: new Map(emptyProjects.map((project) => [project.projectId, project])), + projects, timeline: deserializeTimeline(stored.timeline), }; } +function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { + return { + projectId: workspace.projectId, + projectKey: null, + projectDisplayName: workspace.projectDisplayName, + projectCustomName: workspace.projectCustomName ?? null, + projectRootPath: workspace.projectRootPath, + projectKind: workspace.projectKind, + }; +} + export class ReplicaCache { private readonly activeServerIds = new Set(); private readonly storedHosts = new Map(); @@ -384,6 +415,11 @@ export class ReplicaCache { serverId, agents: focusedAgent ? [serializeAgent(focusedAgent)] : [], workspaces: focusedWorkspace ? [serializeWorkspace(focusedWorkspace)] : [], + projects: focusedWorkspace + ? [session.projects.get(focusedWorkspace.projectId)].flatMap((project) => + project ? [serializeProject(project)] : [], + ) + : [], emptyProjects: [], timeline, }; diff --git a/packages/app/src/schedules/schedule-form-model.test.ts b/packages/app/src/schedules/schedule-form-model.test.ts index 5a50cd33a7..d8daeffeed 100644 --- a/packages/app/src/schedules/schedule-form-model.test.ts +++ b/packages/app/src/schedules/schedule-form-model.test.ts @@ -588,6 +588,28 @@ describe("schedule form model", () => { }); }); + it("hydrates an edited schedule's project label when its host targets arrive", () => { + const form = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-b", + serverName: "Host B", + cwd: "/repo/b", + model: "model-b", + }), + defaults: { serverId: null, projectTargets: [], preferences: {} }, + }); + + expect(form.getState().projectDisplay).toEqual({ label: "/repo/b" }); + + form.applyProjectTargets(PROJECT_TARGETS); + + expect(form.getState()).toMatchObject({ + projectDisplay: { label: "Project B" }, + selectedProjectOptionId: buildProjectOptionId("host-b", "project-b"), + }); + }); + it("hydrates late create preferences without overwriting user changes or edited schedules", () => { const savedPreferences: FormPreferences = { provider: "mock", diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts index 33928ea726..d81c5c92a9 100644 --- a/packages/app/src/schedules/schedule-form-model.ts +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -602,6 +602,11 @@ function updateDerivedState(input: { ...input.state, hosts: [...input.hosts], projectOptions: buildProjectOptions(input.targets, input.state.selectedServerId), + projectDisplay: resolveProjectDisplay({ + targets: input.targets, + serverId: input.state.selectedServerId, + cwd: input.state.workingDir, + }), selectedProjectOptionId: projectTarget?.optionId ?? input.state.selectedProjectOptionId, selectedModelDisplay: resolveModelDisplay({ entries: input.providerEntries, diff --git a/packages/app/src/schedules/schedule-project-targets.test.ts b/packages/app/src/schedules/schedule-project-targets.test.ts index c4c7d500c8..3dd3be0b2c 100644 --- a/packages/app/src/schedules/schedule-project-targets.test.ts +++ b/packages/app/src/schedules/schedule-project-targets.test.ts @@ -21,6 +21,9 @@ function makeProject(overrides: Partial): ProjectSummary { function makeHost(overrides: Partial) { return { serverId: "host-1", + projectId: "project-1", + projectName: "Project", + projectCustomName: null, serverName: "Host 1", isOnline: true, repoRoot: "/tmp/project", @@ -35,14 +38,17 @@ describe("buildScheduleProjectTargets", () => { const targets = buildScheduleProjectTargets([ makeProject({ projectName: "Alpha", - hosts: [makeHost({ repoRoot: "/tmp/alpha" }), makeHost({ serverId: "host-2" })], + hosts: [ + makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" }), + makeHost({ serverId: "host-2", projectName: "Alpha on Host 2" }), + ], }), ]); expect(targets).toHaveLength(2); expect(targets[0]).toMatchObject({ serverId: "host-1", cwd: "/tmp/alpha", - projectName: "Alpha", + projectName: "Alpha on Host 1", }); }); @@ -60,12 +66,15 @@ describe("describeScheduleCwd", () => { it("prefers a matched project name and shortens unmatched paths", () => { const byCwd = buildProjectNameByCwd( buildScheduleProjectTargets([ - makeProject({ projectName: "Alpha", hosts: [makeHost({ repoRoot: "/tmp/alpha" })] }), + makeProject({ + projectName: "Grouped Alpha", + hosts: [makeHost({ projectName: "Alpha on Host 1", repoRoot: "/tmp/alpha" })], + }), ]), ); expect( describeScheduleCwd({ serverId: "host-1", cwd: "/tmp/alpha", projectNameByCwd: byCwd }), - ).toBe("Alpha"); + ).toBe("Alpha on Host 1"); expect( describeScheduleCwd({ serverId: "host-1", cwd: "/Users/sam/api", projectNameByCwd: byCwd }), ).toBe("~/api"); diff --git a/packages/app/src/schedules/schedule-project-targets.ts b/packages/app/src/schedules/schedule-project-targets.ts index 3b7c05a1d1..4ed5e3c5d4 100644 --- a/packages/app/src/schedules/schedule-project-targets.ts +++ b/packages/app/src/schedules/schedule-project-targets.ts @@ -37,7 +37,7 @@ export function buildScheduleProjectTargets( serverId: host.serverId, serverName: host.serverName, projectKey: project.projectKey, - projectName: project.projectName, + projectName: host.projectName, cwd, isGit: Boolean(host.gitRuntime), }); diff --git a/packages/app/src/screens/new-workspace-initial-context.test.ts b/packages/app/src/screens/new-workspace-initial-context.test.ts index 60a272b233..c48f35779c 100644 --- a/packages/app/src/screens/new-workspace-initial-context.test.ts +++ b/packages/app/src/screens/new-workspace-initial-context.test.ts @@ -12,7 +12,7 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem { projectName: key, projectKind: "git", iconWorkingDir: `/work/${key}`, - hosts: [{ serverId, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }], + hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }], workspaceKeys: [], }; } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 11875ccaff..5598633af5 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -66,6 +66,7 @@ import { toErrorMessage } from "@/utils/error-messages"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { getHostProjectSourceDirectory, + getHostProjectId, hostProjectFromRoute, hostProjectFromWorkspace, useHostProjects, @@ -823,6 +824,8 @@ async function createMultiplicityWorkspace(input: { serverId: string; createFailedMessage: string; }): Promise> { + const projectId = getHostProjectId(input.project, input.serverId); + if (!projectId) throw new Error("Project is not available on the selected host"); const isWorktree = input.isolation === "worktree"; const checkoutRequest = isWorktree ? resolveCheckoutRequest(input.selectedItem, input.currentBranch) @@ -836,14 +839,14 @@ async function createMultiplicityWorkspace(input: { ? { kind: "worktree", cwd: input.sourceDirectory, - projectId: input.project.projectKey, + projectId, worktreeSlug: createNameId(), ...checkoutRequest, } : { kind: "directory", path: input.sourceDirectory, - projectId: input.project.projectKey, + projectId, }, ...(firstAgentContext ? { firstAgentContext } : {}), }); @@ -1411,6 +1414,8 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme > { }); }); + it("adopts a routed project's hydrated cross-host key", () => { + const routedProject = { + ...project("local-project-id"), + hosts: [ + { + serverId: "host", + projectId: "local-project-id", + iconWorkingDir: "/work/project", + canCreateWorktree: true, + }, + ], + }; + const hydratedProject = { + ...project("remote:github.com/acme/project"), + hosts: [ + { + serverId: "host", + projectId: "local-project-id", + iconWorkingDir: "/work/project", + canCreateWorktree: false, + }, + ], + }; + const current = createProjectSelection( + context({ initialProject: routedProject, projects: [], routeProject: routedProject }), + ); + const hydratedContext = context({ + initialProject: hydratedProject, + projects: [hydratedProject], + routeProject: routedProject, + }); + + expect(reconcileProjectSelection(current, hydratedContext)).toEqual({ + contextKey: "host:", + projectKey: hydratedProject.projectKey, + project: hydratedProject, + source: "initial", + }); + }); + it("stores hydrated project snapshots before archive gaps", () => { const routeProject = project("route-project"); const hydratedProject: HostProjectListItem = { @@ -184,6 +231,17 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current); }); + it("preserves an opaque project key ending in whitespace", () => { + const selected = project("host:project "); + const current = createProjectSelection( + context({ initialProject: selected, projects: [selected] }), + ); + const currentContext = context({ initialProject: selected, projects: [selected] }); + + expect(resolveProjectSelection(current, currentContext)).toEqual(selected); + expect(reconcileProjectSelection(current, currentContext)).toEqual(current); + }); + it("resets fallback selection when host project capability changes", () => { const fallback = project("git-fallback"); const remembered = project("remembered-directory"); diff --git a/packages/app/src/screens/new-workspace/project-selection.ts b/packages/app/src/screens/new-workspace/project-selection.ts index 58ed75ae43..e6eacd6e65 100644 --- a/packages/app/src/screens/new-workspace/project-selection.ts +++ b/packages/app/src/screens/new-workspace/project-selection.ts @@ -57,7 +57,17 @@ export function resolveInitialProjectSelectionSource(input: { if (!input.initialProject) { return null; } - if (input.routeProject?.projectKey === input.initialProject.projectKey) { + if ( + input.routeProject?.projectKey === input.initialProject.projectKey || + input.routeProject?.hosts.some((routeHost) => + input.initialProject?.hosts.some( + (host) => + host.serverId === routeHost.serverId && + Boolean(host.projectId) && + host.projectId === routeHost.projectId, + ), + ) + ) { return "route"; } if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) { @@ -67,8 +77,8 @@ export function resolveInitialProjectSelectionSource(input: { } function resolveProjectSelectionKey(selection: ProjectSelection): string | null { - const projectKey = selection.projectKey?.trim() ?? ""; - return projectKey || null; + const projectKey = selection.projectKey ?? ""; + return projectKey.trim() ? projectKey : null; } function resolveSelectedProjectFromInitialInputs( @@ -95,14 +105,14 @@ function refreshSelectionProject( }; } -function shouldResetInitialFallbackSelection( +function shouldResetHydratedInitialSelection( selection: ProjectSelection, context: ProjectSelectionContext, ): boolean { if ( selection.source !== "initial" || !context.initialProject || - context.initialProjectSource !== "lastActive" + (context.initialProjectSource !== "route" && context.initialProjectSource !== "lastActive") ) { return false; } @@ -149,7 +159,7 @@ export function reconcileProjectSelection( return initialSelection; } - if (shouldResetInitialFallbackSelection(current, context)) { + if (shouldResetHydratedInitialSelection(current, context)) { return initialSelection; } diff --git a/packages/app/src/screens/project-settings-host-selection.test.ts b/packages/app/src/screens/project-settings-host-selection.test.ts new file mode 100644 index 0000000000..1510f644e8 --- /dev/null +++ b/packages/app/src/screens/project-settings-host-selection.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; +import { resolveProjectSettingsHostTarget } from "./project-settings-host-selection"; + +test("falls back when the selected host is no longer editable", () => { + const hostA = { serverId: "host-a", projectId: "project-a" }; + const hostB = { serverId: "host-b", projectId: "project-b" }; + + expect( + resolveProjectSettingsHostTarget({ + routeKey: "host-a:project-a", + editableHosts: [hostA], + defaultHost: hostA, + selection: { routeKey: "host-a:project-a", ...hostB }, + }), + ).toEqual(hostA); +}); + +test("keeps the selected host while it remains editable", () => { + const hostA = { serverId: "host-a", projectId: "project-a" }; + const hostB = { serverId: "host-b", projectId: "project-b" }; + + expect( + resolveProjectSettingsHostTarget({ + routeKey: "host-a:project-a", + editableHosts: [hostA, hostB], + defaultHost: hostA, + selection: { routeKey: "host-a:project-a", ...hostB }, + }), + ).toEqual(hostB); +}); diff --git a/packages/app/src/screens/project-settings-host-selection.ts b/packages/app/src/screens/project-settings-host-selection.ts new file mode 100644 index 0000000000..ed44f4d726 --- /dev/null +++ b/packages/app/src/screens/project-settings-host-selection.ts @@ -0,0 +1,40 @@ +export interface ProjectSettingsHostTarget { + serverId: string; + projectId: string; +} + +export interface StoredProjectSettingsHostSelection extends ProjectSettingsHostTarget { + routeKey: string; +} + +interface ResolveProjectSettingsHostTargetInput { + routeKey: string; + editableHosts: readonly ProjectSettingsHostTarget[]; + defaultHost: ProjectSettingsHostTarget | undefined; + selection: StoredProjectSettingsHostSelection; +} + +export function resolveProjectSettingsHostTarget( + input: ResolveProjectSettingsHostTargetInput, +): ProjectSettingsHostTarget | undefined { + const selectedHost = + input.selection.routeKey === input.routeKey + ? getEditableHost(input.editableHosts, input.selection) + : undefined; + return ( + selectedHost ?? + getEditableHost(input.editableHosts, input.defaultHost) ?? + input.editableHosts[0] + ); +} + +function getEditableHost( + editableHosts: readonly ProjectSettingsHostTarget[], + target: ProjectSettingsHostTarget | undefined, +): ProjectSettingsHostTarget | undefined { + if (!target) return undefined; + for (const host of editableHosts) { + if (host.serverId === target.serverId && host.projectId === target.projectId) return host; + } + return undefined; +} diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 8a9f5e8bdb..0780c1b6ad 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -46,6 +46,10 @@ import { } from "@/utils/project-config-form"; import { buildProjectsSettingsRoute } from "@/utils/host-routes"; import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; +import { getProjectHostEntry, getProjectSummaryForHostProject } from "@/utils/projects"; +import { useSessionStore } from "@/stores/session-store"; +import { selectProjectIdForServer } from "@/stores/session-store-hooks/selectors"; +import { resolveProjectSettingsHostTarget } from "@/screens/project-settings-host-selection"; const SCRIPT_SERVICE_TYPE = "service"; @@ -84,35 +88,54 @@ const WORKTREE_DOCS_URL = "https://paseo.sh/docs/worktrees"; type ReadProjectConfigData = Awaited>; export interface ProjectSettingsScreenProps { - projectKey: string; + serverId: string; + projectId: string; } -export default function ProjectSettingsScreen({ projectKey }: ProjectSettingsScreenProps) { +export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSettingsScreenProps) { const { projects } = useProjects(); const project = useMemo( - () => projects.find((entry) => entry.projectKey === projectKey), - [projects, projectKey], + () => getProjectSummaryForHostProject(projects, serverId, projectId), + [projectId, projects, serverId], ); const editableHosts = useMemo(() => filterEditableHosts(project), [project]); - - const [selectedServerId, setSelectedServerId] = useState( - () => editableHosts[0]?.serverId ?? "", + const routeKey = `${serverId}:${projectId}`; + const routeHost = getProjectHostEntry(project, serverId, projectId); + const defaultHost = routeHost?.isOnline ? routeHost : editableHosts[0]; + const [hostSelection, setHostSelection] = useState({ + routeKey: "", + serverId: "", + projectId: "", + }); + const selectedTarget = resolveProjectSettingsHostTarget({ + routeKey, + editableHosts, + defaultHost, + selection: hostSelection, + }); + const selectedServerId = selectedTarget?.serverId ?? ""; + const selectedProjectId = selectedTarget?.projectId ?? ""; + const setSelectedServerId = useCallback( + (targetServerId: string) => { + const targetProjectId = selectProjectIdForServer(useSessionStore.getState(), { + sourceServerId: serverId, + projectId, + targetServerId, + }); + if (targetProjectId) { + setHostSelection({ routeKey, serverId: targetServerId, projectId: targetProjectId }); + } + }, + [projectId, routeKey, serverId], ); - useEffect(() => { - const stillValid = editableHosts.some((host) => host.serverId === selectedServerId); - if (!stillValid) { - setSelectedServerId(editableHosts[0]?.serverId ?? ""); - } - }, [editableHosts, selectedServerId]); - const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId); const isHostGone = Boolean(selectedServerId) && (selectedSnapshot?.connectionStatus === "offline" || selectedSnapshot?.connectionStatus === "error"); - const selectedHost = editableHosts.find((host) => host.serverId === selectedServerId); + const selectedHost = getProjectHostEntry(project, selectedServerId, selectedProjectId); const client = useHostRuntimeClient(selectedHost?.serverId ?? ""); if (!project || editableHosts.length === 0 || !selectedHost || !client) { @@ -231,7 +254,7 @@ function ProjectSettingsBody({ const hasMultipleHosts = hosts.length > 1; return ( - + @@ -241,7 +264,13 @@ function ProjectSettingsBody({ projectName={project.projectName} projectKey={project.projectKey} /> - + @@ -789,19 +818,26 @@ function ResolveSpinnerColor(): string { } interface ProjectNameEditorProps { - project: ProjectSummary; + projectName: string; + projectCustomName: string | null; + projectId: string; client: DaemonClient; } -function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { +function ProjectNameEditor({ + projectName, + projectCustomName, + projectId, + client, +}: ProjectNameEditorProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const toast = useToast(); const [isEditing, setIsEditing] = useState(false); - const [value, setValue] = useState(project.projectCustomName ?? ""); + const [value, setValue] = useState(projectCustomName ?? ""); const renameMutation = useMutation({ - mutationFn: (customName: string | null) => client.renameProject(project.projectKey, customName), + mutationFn: (customName: string | null) => client.renameProject(projectId, customName), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setIsEditing(false); @@ -815,24 +851,24 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { }); const handleStartEdit = useCallback(() => { - setValue(project.projectCustomName ?? ""); + setValue(projectCustomName ?? ""); setIsEditing(true); - }, [project.projectCustomName]); + }, [projectCustomName]); const handleCancel = useCallback(() => { setIsEditing(false); - setValue(project.projectCustomName ?? ""); - }, [project.projectCustomName]); + setValue(projectCustomName ?? ""); + }, [projectCustomName]); const handleSave = useCallback(() => { const trimmed = value.trim(); const next = trimmed.length === 0 ? null : trimmed; - if (next === (project.projectCustomName ?? null)) { + if (next === projectCustomName) { setIsEditing(false); return; } renameMutation.mutate(next); - }, [value, project.projectCustomName, renameMutation]); + }, [value, projectCustomName, renameMutation]); const handleReset = useCallback(() => { renameMutation.mutate(null); @@ -840,12 +876,17 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) { if (!isEditing) { return ( - + - {project.projectName} + {projectName} - {project.projectCustomName ? ( + {projectCustomName ? ( + = {}): WorkspaceS function hostEntry(overrides: Partial = {}): ProjectHostEntry { return { serverId: "host-a", + projectId: "project-a", + projectName: "Project", + projectCustomName: null, serverName: "alpha", isOnline: true, repoRoot: "/home/me/proj", @@ -290,7 +293,9 @@ describe("ProjectsScreen", () => { vi.unstubAllGlobals(); }); - function render(view: { kind: "projects" } | { kind: "project"; projectKey: string }) { + function render( + view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string }, + ) { act(() => { root?.render(); }); @@ -329,7 +334,7 @@ describe("ProjectsScreen", () => { }); expect(navigate).toHaveBeenCalledTimes(1); - expect(navigate).toHaveBeenCalledWith("/settings/projects/remote%3Agithub.com%2Facme%2Fapp"); + expect(navigate).toHaveBeenCalledWith("/settings/projects/host-a/project-a"); }); it("does not render a kebab menu on the row", () => { @@ -392,7 +397,7 @@ describe("ProjectsScreen", () => { ], }); - render({ kind: "project", projectKey: "remote:github.com/acme/app" }); + render({ kind: "project", serverId: "host-a", projectId: "project-a" }); const selected = findRow(container!, "remote:github.com/acme/app"); const other = findRow(container!, "remote:github.com/acme/other"); diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx index f3e7c52a8e..7dc76e952e 100644 --- a/packages/app/src/screens/projects-screen.tsx +++ b/packages/app/src/screens/projects-screen.tsx @@ -10,16 +10,20 @@ import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { settingsStyles } from "@/styles/settings"; import { buildProjectSettingsRoute } from "@/utils/host-routes"; -import type { ProjectSummary } from "@/utils/projects"; +import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects"; interface ProjectsScreenProps { - view: { kind: "projects" } | { kind: "project"; projectKey: string }; + view: { kind: "projects" } | { kind: "project"; serverId: string; projectId: string }; } export default function ProjectsScreen({ view }: ProjectsScreenProps) { const { t } = useTranslation(); const { projects, hostErrors, isLoading } = useProjects(); - const selectedProjectKey = view.kind === "project" ? view.projectKey : null; + const selectedProjectKey = + view.kind === "project" + ? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ?? + null) + : null; const iconTargets = useMemo( () => projects.flatMap((project) => { @@ -100,10 +104,10 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro const { t } = useTranslation(); const { theme } = useUnistyles(); const { projectKey, projectName } = project; - + const target = project.hosts[0]; const handleNavigate = useCallback(() => { - router.navigate(buildProjectSettingsRoute(projectKey)); - }, [projectKey]); + if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId)); + }, [target]); const rowStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index aebdeb0860..0c2ea7d969 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -129,7 +129,7 @@ export type SettingsView = | { kind: "section"; section: SettingsSectionSlug } | { kind: "host"; serverId: string; section: HostSectionSlug } | { kind: "projects" } - | { kind: "project"; projectKey: string }; + | { kind: "project"; serverId: string; projectId: string }; interface SidebarSectionItem { id: SettingsSectionSlug; @@ -1386,7 +1386,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti return ; } if (view.kind === "project") { - return ; + return ; } if (view.kind === "section") { switch (view.section) { diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index dde20788a8..9b57fac51e 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -19,7 +19,7 @@ import { } from "./selectors"; import { useSessionStore, - type EmptyProjectDescriptor, + type ProjectDescriptor, type WorkspaceDescriptor, } from "../session-store"; @@ -45,11 +45,33 @@ function createWorkspace( }; } +function projectDescriptorFromTestWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { + return { + projectId: workspace.projectId, + projectKey: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + projectCustomName: workspace.projectCustomName ?? null, + projectRootPath: workspace.projectRootPath, + projectKind: workspace.projectKind, + }; +} + function initializeWorkspaces(workspaces: WorkspaceDescriptor[]): void { useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); useSessionStore .getState() .setWorkspaces(SERVER_ID, new Map(workspaces.map((workspace) => [workspace.id, workspace]))); + useSessionStore + .getState() + .setProjects( + SERVER_ID, + new Map( + workspaces.map((workspace) => [ + workspace.projectId, + projectDescriptorFromTestWorkspace(workspace), + ]), + ).values(), + ); } interface Subscribable { @@ -125,11 +147,26 @@ describe("workspace replica authority", () => { useSessionStore .getState() .setWorkspaces(SERVER_ID, new Map([[authoritativeWorkspace.id, authoritativeWorkspace]])); + useSessionStore + .getState() + .setProjects( + SERVER_ID, + new Map([ + [ + authoritativeWorkspace.projectId, + projectDescriptorFromTestWorkspace(authoritativeWorkspace), + ], + ]).values(), + ); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); const hydratedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [ SERVER_ID, ]); + expect(hydratedServerIds).toEqual([SERVER_ID]); + expect( + Array.from(useSessionStore.getState().sessions[SERVER_ID]?.projects.keys() ?? []), + ).toEqual([authoritativeWorkspace.projectId]); expect( selectWorkspaceStructureProjects(useSessionStore.getState(), hydratedServerIds).map( (project) => project.workspaceKeys, @@ -149,10 +186,16 @@ describe("workspace replica authority", () => { sessions: { [loadingServerId]: { hasHydratedWorkspaces: false, + projects: new Map([ + [loadingWorkspace.projectId, projectDescriptorFromTestWorkspace(loadingWorkspace)], + ]), workspaces: new Map([[loadingWorkspace.id, loadingWorkspace]]), }, [hydratedServerId]: { hasHydratedWorkspaces: true, + projects: new Map([ + [hydratedWorkspace.projectId, projectDescriptorFromTestWorkspace(hydratedWorkspace)], + ]), workspaces: new Map([[hydratedWorkspace.id, hydratedWorkspace]]), }, }, @@ -295,8 +338,9 @@ describe("workspace structure composition", () => { projectRootPath: "/repo/a", workspaceDirectory: "/repo/a", }); - const emptyProject: EmptyProjectDescriptor = { + const emptyProject: ProjectDescriptor = { projectId: "project-a", + projectKey: "project-a", projectDisplayName: "Project A", projectCustomName: null, projectRootPath: "/repo/a", @@ -311,7 +355,7 @@ describe("workspace structure composition", () => { try { useSessionStore.getState().removeWorkspace(SERVER_ID, workspace.id); - useSessionStore.getState().addEmptyProject(SERVER_ID, emptyProject); + useSessionStore.getState().upsertProject(SERVER_ID, emptyProject); } finally { stop(); } @@ -348,15 +392,15 @@ describe("workspace structure composition", () => { it("renders a project parent with zero active workspaces", () => { useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); useSessionStore.getState().setWorkspaces(SERVER_ID, new Map()); - useSessionStore.getState().setEmptyProjects(SERVER_ID, [ - { - projectId: "empty-project", - projectDisplayName: "Empty Project", - projectCustomName: null, - projectRootPath: "/repo/empty", - projectKind: "git", - }, - ]); + const emptyProject: ProjectDescriptor = { + projectId: "empty-project", + projectKey: "empty-project", + projectDisplayName: "Empty Project", + projectCustomName: null, + projectRootPath: "/repo/empty", + projectKind: "git", + }; + useSessionStore.getState().setProjects(SERVER_ID, [emptyProject]); const projects = selectWorkspaceStructureProjects(useSessionStore.getState(), [SERVER_ID]); expect(projects).toEqual([ @@ -368,7 +412,7 @@ describe("workspace structure composition", () => { ]); }); - it("changes when a structure-relevant project identity field changes", () => { + it("changes when a project descriptor display field changes", () => { const workspace = createWorkspace({ id: "workspace-a", projectDisplayName: "Project 1", @@ -382,9 +426,10 @@ describe("workspace structure composition", () => { ); const before = tracked.current; - useSessionStore - .getState() - .mergeWorkspaces(SERVER_ID, [{ ...workspace, projectDisplayName: "Project Renamed" }]); + useSessionStore.getState().upsertProject(SERVER_ID, { + ...projectDescriptorFromTestWorkspace(workspace), + projectDisplayName: "Project Renamed", + }); expect(tracked.current).not.toBe(before); tracked.stop(); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index d0377cd58e..bbc243423a 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -6,7 +6,7 @@ import { } from "@/projects/workspace-structure"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; -import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store"; +import type { ProjectDescriptor, WorkspaceDescriptor } from "../session-store"; export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; export type { WorkspaceStructure, WorkspaceStructureProject } from "@/projects/workspace-structure"; @@ -17,7 +17,7 @@ export interface SessionsSnapshot { { hasHydratedWorkspaces?: boolean; workspaces: Map; - emptyProjects?: Map; + projects?: Map; } >; } @@ -145,20 +145,20 @@ export function selectWorkspaceStructureProjects( const sessions: Array<{ serverId: string; workspaces: Iterable; - emptyProjects: Iterable; + projects: Iterable; }> = []; for (const serverId of serverIds) { const session = state.sessions[serverId]; const workspaces = session?.workspaces; - const emptyProjects = session?.emptyProjects; - if ((!workspaces || workspaces.size === 0) && (!emptyProjects || emptyProjects.size === 0)) { + const projects = session?.projects; + if (!projects || projects.size === 0) { continue; } sessions.push({ serverId, workspaces: workspaces?.values() ?? [], - emptyProjects: emptyProjects?.values() ?? [], + projects: projects.values(), }); } @@ -169,6 +169,32 @@ export function selectWorkspaceStructureProjects( return buildWorkspaceStructureProjects({ sessions }); } +export function selectProject( + state: SessionsSnapshot, + serverId: string | null, + projectId: string | null, +): ProjectDescriptor | null { + if (!serverId || !projectId) return null; + return state.sessions[serverId]?.projects?.get(projectId) ?? null; +} + +export function selectProjectIdForServer( + state: SessionsSnapshot, + input: { + sourceServerId: string; + projectId: string; + targetServerId: string; + }, +): string | null { + if (input.sourceServerId === input.targetServerId) return input.projectId; + const source = selectProject(state, input.sourceServerId, input.projectId); + if (!source?.projectKey) return null; + for (const project of state.sessions[input.targetServerId]?.projects?.values() ?? []) { + if (project.projectKey === source.projectKey) return project.projectId; + } + return null; +} + export function selectProjectOrder(state: SidebarOrderSnapshot): string[] { return state.projectOrder ?? EMPTY_WORKSPACE_KEYS; } diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 1588fd114a..8ffed6bff8 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -49,7 +49,6 @@ function getTestSessionReferences() { sessions: state.sessions, session, workspaces: session.workspaces, - emptyProjects: session.emptyProjects, }; } @@ -405,48 +404,6 @@ describe("removeWorkspace", () => { }); }); -describe("removeEmptyProject", () => { - it("removes an empty project by project id", () => { - const store = useSessionStore.getState(); - initializeTestSession(); - store.setEmptyProjects("test-server", [ - { - projectId: "project-empty", - projectDisplayName: "Empty", - projectCustomName: null, - projectRootPath: "/repo/empty", - projectKind: "git", - }, - ]); - - store.removeEmptyProject("test-server", "project-empty"); - - expect(getTestSessionReferences().emptyProjects.has("project-empty")).toBe(false); - }); - - it("preserves identity when removing a missing empty project", () => { - const store = useSessionStore.getState(); - initializeTestSession(); - store.setEmptyProjects("test-server", [ - { - projectId: "project-empty", - projectDisplayName: "Empty", - projectCustomName: null, - projectRootPath: "/repo/empty", - projectKind: "git", - }, - ]); - const before = getTestSessionReferences(); - - store.removeEmptyProject("test-server", "project-missing"); - const after = getTestSessionReferences(); - - expect(after.sessions).toBe(before.sessions); - expect(after.session).toBe(before.session); - expect(after.emptyProjects).toBe(before.emptyProjects); - }); -}); - describe("patchWorkspaceScripts", () => { it("preserves workspace entry identity when scripts are content-equal", () => { const script = { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 7ff1bc6a25..c0c39293a7 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -182,19 +182,21 @@ export function normalizeWorkspaceDescriptor( }; } -export interface EmptyProjectDescriptor { +export interface ProjectDescriptor { projectId: string; + projectKey?: string | null; projectDisplayName: string; projectCustomName: string | null; projectRootPath: string; projectKind: WorkspaceDescriptorPayload["projectKind"]; } -export function normalizeEmptyProjectDescriptor( +export function normalizeProjectDescriptor( payload: WorkspaceProjectDescriptorPayload, -): EmptyProjectDescriptor { +): ProjectDescriptor { return { projectId: payload.projectId, + projectKey: payload.projectKey ?? null, projectDisplayName: payload.projectDisplayName, projectCustomName: payload.projectCustomName ?? null, projectRootPath: payload.projectRootPath, @@ -237,28 +239,15 @@ function preserveWorkspaceMapIdentity( return changed ? next : existing; } -function emptyProjectDescriptorFromWorkspace( - workspace: WorkspaceDescriptor, -): EmptyProjectDescriptor { - return { - projectId: workspace.projectId, - projectDisplayName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, - projectRootPath: workspace.projectRootPath, - projectKind: workspace.projectKind, - }; -} - -function hasWorkspaceInProject( - workspaces: ReadonlyMap, - projectId: string, +function projectMapsEqual( + left: ReadonlyMap, + right: ReadonlyMap, ): boolean { - for (const workspace of workspaces.values()) { - if (workspace.projectId === projectId) { - return true; - } + if (left.size !== right.size) return false; + for (const [projectId, project] of right) { + if (!equal(left.get(projectId), project)) return false; } - return false; + return true; } export type ExplorerEntryKind = "file" | "directory"; @@ -332,7 +321,7 @@ export interface SessionReplicaTimeline { export interface SessionReplica { agents: Map; workspaces: Map; - emptyProjects: Map; + projects: Map; timeline: SessionReplicaTimeline | null; } @@ -383,9 +372,8 @@ export interface SessionState { workspaceAgentActivity: Map; agentDetails: Map; workspaces: Map; - // Project parents with no active workspaces, keyed by projectId. The - // `emptyProjects` name is the existing protocol/store projection. - emptyProjects: Map; + // All active project descriptors, keyed by host-local projectId. + projects: Map; // Transient restore state for archived workspaces, keyed by normalized // workspaceId. Cleared in mergeWorkspaces when the descriptor lands. restoringWorkspaces: Map; @@ -512,9 +500,9 @@ interface SessionStoreActions { ) => void; mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; removeWorkspace: (serverId: string, workspaceId: string) => void; - setEmptyProjects: (serverId: string, emptyProjects: Iterable) => void; - addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void; - removeEmptyProject: (serverId: string, projectId: string) => void; + setProjects: (serverId: string, projects: Iterable) => void; + upsertProject: (serverId: string, project: ProjectDescriptor) => void; + removeProject: (serverId: string, projectId: string) => void; setWorkspaceRestoreStatus: ( serverId: string, workspaceId: string, @@ -599,7 +587,7 @@ function createInitialSessionState( workspaceAgentActivity: new Map(), agentDetails: new Map(), workspaces: new Map(), - emptyProjects: new Map(), + projects: new Map(), restoringWorkspaces: new Map(), pendingPermissions: new Map(), fileExplorer: new Map(), @@ -728,7 +716,7 @@ export const useSessionStore = create()( agents: replica.agents, workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents), workspaces: replica.workspaces, - emptyProjects: replica.emptyProjects, + projects: replica.projects, agentStreamTail, agentTimelineCursor, agentTimelineHasOlder, @@ -1363,65 +1351,41 @@ export const useSessionStore = create()( }); }, - setEmptyProjects: (serverId, emptyProjects) => { - const next = new Map(); - for (const project of emptyProjects) { - next.set(project.projectId, project); - } + setProjects: (serverId, projects) => { + const next = new Map(); + for (const project of projects) next.set(project.projectId, project); set((prev) => { const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - if (session.emptyProjects.size === 0 && next.size === 0) { - return prev; - } + if (!session || projectMapsEqual(session.projects, next)) return prev; return { ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, emptyProjects: next }, - }, + sessions: { ...prev.sessions, [serverId]: { ...session, projects: next } }, }; }); }, - addEmptyProject: (serverId, emptyProject) => { + upsertProject: (serverId, project) => { set((prev) => { const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const existing = session.emptyProjects.get(emptyProject.projectId); - if (existing && equal(existing, emptyProject)) { - return prev; - } - const next = new Map(session.emptyProjects); - next.set(emptyProject.projectId, emptyProject); + if (!session || equal(session.projects.get(project.projectId), project)) return prev; + const projects = new Map(session.projects); + projects.set(project.projectId, project); return { ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, emptyProjects: next }, - }, + sessions: { ...prev.sessions, [serverId]: { ...session, projects } }, }; }); }, - removeEmptyProject: (serverId, projectId) => { + removeProject: (serverId, projectId) => { set((prev) => { const session = prev.sessions[serverId]; - if (!session?.emptyProjects.has(projectId)) { - return prev; - } - const next = new Map(session.emptyProjects); - next.delete(projectId); + if (!session?.projects.has(projectId)) return prev; + const projects = new Map(session.projects); + projects.delete(projectId); return { ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, emptyProjects: next }, - }, + sessions: { ...prev.sessions, [serverId]: { ...session, projects } }, }; }); }, @@ -1483,17 +1447,10 @@ export const useSessionStore = create()( } const next = new Map(session.workspaces); let changed = false; - // A workspace landing in a project means that project is no longer - // empty: prune any stale empty descriptor so it stops governing the - // project's rendered metadata. - const nextEmptyProjects = new Map(session.emptyProjects); // A descriptor arriving is the success signal for a pending restore: // clear it at the source so every entry point converges to "ready". let nextRestoring: Map | null = null; for (const workspace of nextEntries) { - if (nextEmptyProjects.delete(workspace.projectId)) { - changed = true; - } if (session.restoringWorkspaces.has(workspace.id)) { nextRestoring ??= new Map(session.restoringWorkspaces); nextRestoring.delete(workspace.id); @@ -1517,7 +1474,6 @@ export const useSessionStore = create()( [serverId]: { ...session, workspaces: next, - emptyProjects: nextEmptyProjects, restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces, }, }, @@ -1535,31 +1491,13 @@ export const useSessionStore = create()( if (!session || !workspaceKey) { return prev; } - const removedWorkspace = session.workspaces.get(workspaceKey); - if (!removedWorkspace) { - return prev; - } const next = new Map(session.workspaces); next.delete(workspaceKey); - let nextEmptyProjects = session.emptyProjects; - if (hasWorkspaceInProject(next, removedWorkspace.projectId)) { - if (nextEmptyProjects.has(removedWorkspace.projectId)) { - nextEmptyProjects = new Map(nextEmptyProjects); - nextEmptyProjects.delete(removedWorkspace.projectId); - } - } else { - const emptyProject = emptyProjectDescriptorFromWorkspace(removedWorkspace); - const existing = nextEmptyProjects.get(emptyProject.projectId); - if (!existing || !equal(existing, emptyProject)) { - nextEmptyProjects = new Map(nextEmptyProjects); - nextEmptyProjects.set(emptyProject.projectId, emptyProject); - } - } return { ...prev, sessions: { ...prev.sessions, - [serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects }, + [serverId]: { ...session, workspaces: next }, }, }; }); diff --git a/packages/app/src/test/seed-session.ts b/packages/app/src/test/seed-session.ts new file mode 100644 index 0000000000..23848acd24 --- /dev/null +++ b/packages/app/src/test/seed-session.ts @@ -0,0 +1,41 @@ +import { + useSessionStore, + type ProjectDescriptor, + type WorkspaceDescriptor, +} from "@/stores/session-store"; + +function projectFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { + return { + projectId: workspace.projectId, + // A daemon always derives a key for every project, so fixtures carry one too. + // Same key on two hosts means the same project, which is what grouping asserts. + projectKey: workspace.projectId, + projectDisplayName: workspace.projectDisplayName, + projectCustomName: workspace.projectCustomName ?? null, + projectRootPath: workspace.projectRootPath, + projectKind: workspace.projectKind, + }; +} + +/** + * Seeds a session the way a daemon populates it: every workspace's project is + * published on the project channel, so the store never holds a workspace whose + * project is unknown. Tests that seed workspaces alone model a state the daemon + * cannot produce, and nothing renders. + */ +export function seedSessionWorkspaces( + serverId: string, + workspaces: Map, + projects?: Iterable, +): void { + const byProjectId = new Map(); + for (const project of projects ?? []) byProjectId.set(project.projectId, project); + for (const workspace of workspaces.values()) { + if (!byProjectId.has(workspace.projectId)) { + byProjectId.set(workspace.projectId, projectFromWorkspace(workspace)); + } + } + const store = useSessionStore.getState(); + store.setProjects(serverId, byProjectId.values()); + store.setWorkspaces(serverId, workspaces); +} diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index fbc8115fce..7b13633a3d 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -17,6 +17,7 @@ import { encodeWorkspaceIdForPathSegment, isSettingsSectionSlug, normalizeHostSectionSlug, + normalizeProjectSettingsRouteId, parseHostAgentRouteFromPathname, parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, @@ -196,23 +197,14 @@ describe("projects settings routes", () => { expect(buildProjectsSettingsRoute()).toBe("/settings/projects"); }); - it("buildProjectSettingsRoute encodes a remote project key as a single segment", () => { - expect(buildProjectSettingsRoute("remote:github.com/acme/app")).toBe( - "/settings/projects/remote%3Agithub.com%2Facme%2Fapp", + it("buildProjectSettingsRoute addresses a host-local project id", () => { + expect(buildProjectSettingsRoute("host a", "project/1")).toBe( + "/settings/projects/host%20a/project%2F1", ); }); - it("buildProjectSettingsRoute encodes a local repo-root key", () => { - expect(buildProjectSettingsRoute("/Users/me/dev/paseo")).toBe( - "/settings/projects/%2FUsers%2Fme%2Fdev%2Fpaseo", - ); - }); - - it("project keys round-trip through decodeURIComponent", () => { - const projectKey = "remote:github.com/acme/app"; - const route = buildProjectSettingsRoute(projectKey); - const segment = route.slice("/settings/projects/".length); - expect(decodeURIComponent(segment)).toBe(projectKey); + it("keeps route ids opaque", () => { + expect(normalizeProjectSettingsRouteId("project%2F1")).toBe("project%2F1"); }); }); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 118ea2c406..4cc2c141e0 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -566,10 +566,14 @@ export function buildProjectsSettingsRoute() { return "/settings/projects" as const; } -export function buildProjectSettingsRoute(projectKey: string) { - const normalized = trimNonEmpty(projectKey); - if (!normalized) { - throw new Error("buildProjectSettingsRoute requires a non-empty projectKey"); +export function buildProjectSettingsRoute(serverId: string, projectId: string) { + if (!serverId.trim() || !projectId.trim()) { + throw new Error("buildProjectSettingsRoute requires a serverId and projectId"); } - return `/settings/projects/${encodeSegment(normalized)}` as const; + return `/settings/projects/${encodeSegment(serverId)}/${encodeSegment(projectId)}` as const; +} + +export function normalizeProjectSettingsRouteId(value: string | string[] | undefined): string { + const id = Array.isArray(value) ? value[0] : value; + return typeof id === "string" ? id : ""; } diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index 62f39ca2c3..c8a41b2975 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -1,595 +1,107 @@ -import { describe, expect, it } from "vitest"; -import type { ProjectPlacementPayload } from "@getpaseo/protocol/messages"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { buildProjects } from "./projects"; +import { describe, expect, test } from "vitest"; +import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; +import { buildProjects, getProjectSummaryForHostProject } from "./projects"; -function placement(input: { - projectKey: string; - projectName: string; - cwd: string; - remoteUrl: string | null; - mainRepoRoot?: string | null; -}): ProjectPlacementPayload { +function descriptor(id: string, key: string, root: string): ProjectDescriptor { return { - projectKey: input.projectKey, - projectName: input.projectName, - checkout: { - cwd: input.cwd, - isGit: true, - currentBranch: "main", - remoteUrl: input.remoteUrl, - worktreeRoot: input.cwd, - isPaseoOwnedWorktree: false, - mainRepoRoot: input.mainRepoRoot ?? null, - }, + projectId: id, + projectKey: key, + projectDisplayName: "acme/app", + projectCustomName: null, + projectRootPath: root, + projectKind: "git", }; } -function workspace(input: { - id: string; - repoRoot: string; - project?: ProjectPlacementPayload; - projectId?: string; - projectName?: string; - remoteUrl?: string | null; - currentBranch?: string; - archivingAt?: string | null; -}): WorkspaceDescriptor { +function workspace(id: string, projectId: string, root: string): WorkspaceDescriptor { return { - id: input.id, - projectId: input.projectId ?? input.project?.projectKey ?? input.repoRoot, - projectDisplayName: input.projectName ?? input.project?.projectName ?? "Project", - projectRootPath: input.repoRoot, - workspaceDirectory: input.repoRoot, + id, + projectId, + projectDisplayName: "acme/app", + projectCustomName: null, + projectRootPath: root, + workspaceDirectory: root, projectKind: "git", workspaceKind: "local_checkout", - name: input.id, + name: "main", status: "done", - archivingAt: input.archivingAt ?? null, statusEnteredAt: null, + archivingAt: null, diffStat: null, scripts: [], - gitRuntime: { - currentBranch: input.currentBranch ?? "main", - remoteUrl: input.remoteUrl ?? input.project?.checkout.remoteUrl ?? null, - isPaseoOwnedWorktree: false, - isDirty: false, - aheadBehind: null, - aheadOfOrigin: null, - behindOfOrigin: null, - }, - githubRuntime: null, - project: input.project, }; } describe("buildProjects", () => { - it("normalizes detached and blank branches out of workspace summaries", () => { + test("uses the grouped project list while retaining each host project id", () => { + const key = "remote:github.com/acme/app"; const result = buildProjects({ hosts: [ { - serverId: "local", - serverName: "Local", + serverId: "host-a", + serverName: "Host A", isOnline: true, - workspaces: [ - workspace({ id: "detached", repoRoot: "/repo/app", currentBranch: "HEAD" }), - workspace({ id: "blank", repoRoot: "/repo/app", currentBranch: " " }), - ], - }, - ], - }); - - expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([ - { id: "detached", currentBranch: null }, - { id: "blank", currentBranch: null }, - ]); - }); - - it("carries active archive state into workspace summaries", () => { - const archivingAt = "2026-07-15T18:00:00.000Z"; - const result = buildProjects({ - hosts: [ - { - serverId: "local", - serverName: "Local", - isOnline: true, - workspaces: [ - workspace({ id: "archiving", repoRoot: "/repo/app", archivingAt }), - workspace({ id: "active", repoRoot: "/repo/app" }), - ], - }, - ], - }); - - expect(result.projects[0]?.hosts[0]?.workspaces).toMatchObject([ - { id: "archiving", archivingAt }, - { id: "active" }, - ]); - expect(result.projects[0]?.hosts[0]?.workspaces[1]).not.toHaveProperty("archivingAt"); - }); - - it("groups two daemons with the same GitHub project key into one project with one host entry per daemon", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "local", - serverName: "Local", - isOnline: true, - workspaces: [ - workspace({ - id: "main", - repoRoot: "/repo/app", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - }), - workspace({ - id: "feature-a", - repoRoot: "/repo/app", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app/feature-a", - remoteUrl: "https://github.com/acme/app.git", - }), - }), - workspace({ - id: "feature-b", - repoRoot: "/repo/app", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app/feature-b", - remoteUrl: "https://github.com/acme/app.git", - }), - }), - ], + projects: [descriptor("prj_a", key, "/a/app")], + workspaces: [workspace("ws-a", "prj_a", "/a/app")], }, { - serverId: "laptop", - serverName: "Laptop", - isOnline: true, - workspaces: [ - workspace({ - id: "main", - repoRoot: "/work/app", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/work/app", - remoteUrl: "git@github.com:acme/app.git", - }), - }), - workspace({ - id: "feature", - repoRoot: "/work/app", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/work/app/feature", - remoteUrl: "git@github.com:acme/app.git", - }), - }), - ], - }, - ], - }); - - expect(result.projects).toHaveLength(1); - const summary = result.projects[0]; - expect(summary?.projectKey).toBe("remote:github.com/acme/app"); - expect(summary?.projectName).toBe("acme/app"); - expect(summary?.hostCount).toBe(2); - expect(summary?.onlineHostCount).toBe(2); - expect(summary?.totalWorkspaceCount).toBe(5); - expect(summary?.githubUrl).toBe("https://github.com/acme/app"); - expect(summary?.hosts).toHaveLength(2); - const local = summary?.hosts.find((host) => host.serverId === "local"); - const laptop = summary?.hosts.find((host) => host.serverId === "laptop"); - expect(local?.workspaceCount).toBe(3); - expect(laptop?.workspaceCount).toBe(2); - expect(local?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature-a", "feature-b"]); - expect(laptop?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature"]); - }); - - it("collapses five workspaces on one host into a single host entry whose workspaceCount is five", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "local", - serverName: "Local", - isOnline: true, - workspaces: Array.from({ length: 5 }, (_, index) => - workspace({ - id: `ws-${index}`, - repoRoot: "/repo/app", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: `/repo/app/ws-${index}`, - remoteUrl: "https://github.com/acme/app.git", - }), - }), - ), - }, - ], - }); - - expect(result.projects).toHaveLength(1); - expect(result.projects[0]?.hosts).toHaveLength(1); - expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(5); - expect(result.projects[0]?.totalWorkspaceCount).toBe(5); - expect(result.projects[0]?.hostCount).toBe(1); - }); - - it("prefers placement mainRepoRoot for the host repoRoot and falls back to projectRootPath when placement is absent", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "local", - serverName: "Local", - isOnline: true, - workspaces: [ - workspace({ - id: "main", - repoRoot: "/worktrees/app/main", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/worktrees/app/main", - remoteUrl: "https://github.com/acme/app.git", - mainRepoRoot: "/repo/app", - }), - }), - ], - }, - { - serverId: "legacy", - serverName: "Legacy", - isOnline: true, - workspaces: [ - workspace({ - id: "legacy", - repoRoot: "/repo/legacy", - projectId: "legacy-project", - projectName: "Legacy", - }), - ], - }, - ], - }); - - const acme = result.projects.find( - (project) => project.projectKey === "remote:github.com/acme/app", - ); - const legacy = result.projects.find((project) => project.projectKey === "legacy-project"); - - expect(acme?.hosts[0]?.repoRoot).toBe("/repo/app"); - expect(legacy?.hosts[0]?.repoRoot).toBe("/repo/legacy"); - }); - - it("derives githubUrl only when projectKey matches remote:github.com/{owner}/{repo}", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "local", - serverName: "Local", - isOnline: true, - workspaces: [ - workspace({ - id: "github", - repoRoot: "/repo/app", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - }), - workspace({ - id: "local", - repoRoot: "/repo/local", - project: placement({ - projectKey: "/repo/local", - projectName: "local", - cwd: "/repo/local", - remoteUrl: null, - }), - }), - ], - }, - ], - }); - - const github = result.projects.find( - (project) => project.projectKey === "remote:github.com/acme/app", - ); - const local = result.projects.find((project) => project.projectKey === "/repo/local"); - - expect(github?.githubUrl).toBe("https://github.com/acme/app"); - expect(local?.githubUrl).toBeUndefined(); - }); - - it("totals hostCount across all hosts and counts only online ones in onlineHostCount", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "online", - serverName: "Online", - isOnline: true, - workspaces: [ - workspace({ - id: "ws", - repoRoot: "/repo/app", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - }), - ], - }, - { - serverId: "offline", - serverName: "Offline", + serverId: "host-b", + serverName: "Host B", isOnline: false, - workspaces: [ - workspace({ - id: "ws", - repoRoot: "/repo/app", - project: placement({ - projectKey: "remote:github.com/acme/app", - projectName: "acme/app", - cwd: "/repo/app", - remoteUrl: "https://github.com/acme/app.git", - }), - }), - ], + projects: [descriptor("prj_b", key, "/b/app")], + workspaces: [workspace("ws-b", "prj_b", "/b/app")], }, ], }); - expect(result.projects).toHaveLength(1); - expect(result.projects[0]?.hostCount).toBe(2); - expect(result.projects[0]?.onlineHostCount).toBe(1); - expect(result.projects[0]?.hosts.find((host) => host.serverId === "online")?.isOnline).toBe( - true, - ); - expect(result.projects[0]?.hosts.find((host) => host.serverId === "offline")?.isOnline).toBe( - false, - ); - }); - - it("does not merge fallback repo-root-keyed projects with different roots", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "local", - serverName: "Local", - isOnline: true, - workspaces: [ - workspace({ - id: "one", - repoRoot: "/repo/one", - project: placement({ - projectKey: "/repo/one", - projectName: "one", - cwd: "/repo/one", - remoteUrl: null, - }), - }), - workspace({ - id: "two", - repoRoot: "/repo/two", - project: placement({ - projectKey: "/repo/two", - projectName: "two", - cwd: "/repo/two", - remoteUrl: null, - }), - }), - ], - }, - ], - }); - - expect(result.projects.map((project) => project.projectKey)).toEqual([ - "/repo/one", - "/repo/two", + expect(result.projects).toEqual([ + expect.objectContaining({ + projectKey: key, + hostCount: 2, + onlineHostCount: 1, + totalWorkspaceCount: 2, + hosts: [ + expect.objectContaining({ serverId: "host-a", projectId: "prj_a" }), + expect.objectContaining({ serverId: "host-b", projectId: "prj_b" }), + ], + }), ]); }); - it("includes GitHub, GitLab, Bitbucket and local projects together, sorted by name", () => { + test("includes a project with no workspaces", () => { const result = buildProjects({ hosts: [ { - serverId: "local", - serverName: "Local", - isOnline: true, - workspaces: [ - workspace({ - id: "github", - repoRoot: "/repo/github", - project: placement({ - projectKey: "remote:github.com/acme/web", - projectName: "acme/web", - cwd: "/repo/github", - remoteUrl: "https://github.com/acme/web.git", - }), - }), - workspace({ - id: "gitlab", - repoRoot: "/repo/gitlab", - project: placement({ - projectKey: "remote:gitlab.com/acme/api", - projectName: "acme/api", - cwd: "/repo/gitlab", - remoteUrl: "https://gitlab.com/acme/api.git", - }), - }), - workspace({ - id: "bitbucket", - repoRoot: "/repo/bitbucket", - project: placement({ - projectKey: "remote:bitbucket.org/acme/cli", - projectName: "acme/cli", - cwd: "/repo/bitbucket", - remoteUrl: "https://bitbucket.org/acme/cli.git", - }), - }), - workspace({ - id: "local", - repoRoot: "/repo/local", - project: placement({ - projectKey: "/repo/local", - projectName: "local", - cwd: "/repo/local", - remoteUrl: null, - }), - }), - ], - }, - ], - }); - - expect(result.projects.map((project) => project.projectKey)).toEqual([ - "remote:gitlab.com/acme/api", - "remote:bitbucket.org/acme/cli", - "remote:github.com/acme/web", - "/repo/local", - ]); - }); - - it("renders a non-GitHub remote project on its own when no other projects are present", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "local", - serverName: "Local", - isOnline: true, - workspaces: [ - workspace({ - id: "gitlab", - repoRoot: "/repo/gitlab", - project: placement({ - projectKey: "remote:gitlab.com/acme/app", - projectName: "acme/app", - cwd: "/repo/gitlab", - remoteUrl: "https://gitlab.com/acme/app.git", - }), - }), - ], - }, - ], - }); - - expect(result.projects).toHaveLength(1); - expect(result.projects[0]?.projectKey).toBe("remote:gitlab.com/acme/app"); - expect(result.projects[0]?.projectName).toBe("acme/app"); - expect(result.projects[0]?.githubUrl).toBeUndefined(); - }); - - it("groups two workspaces sharing a non-GitHub remote into one project with workspaceCount two", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "local", - serverName: "Local", - isOnline: true, - workspaces: [ - workspace({ - id: "main", - repoRoot: "/repo/gitlab", - project: placement({ - projectKey: "remote:gitlab.com/acme/app", - projectName: "acme/app", - cwd: "/repo/gitlab", - remoteUrl: "https://gitlab.com/acme/app.git", - }), - }), - workspace({ - id: "feature", - repoRoot: "/repo/gitlab", - project: placement({ - projectKey: "remote:gitlab.com/acme/app", - projectName: "acme/app", - cwd: "/repo/gitlab/feature", - remoteUrl: "https://gitlab.com/acme/app.git", - }), - }), - ], - }, - ], - }); - - expect(result.projects).toHaveLength(1); - expect(result.projects[0]?.hosts).toHaveLength(1); - expect(result.projects[0]?.hosts[0]?.workspaceCount).toBe(2); - }); - - it("falls back conservatively for mixed-version daemons whose descriptors lack project", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "old-daemon", - serverName: "Old daemon", - isOnline: true, - workspaces: [ - workspace({ - id: "legacy", - repoRoot: "/repo/legacy", - projectId: "legacy-project", - projectName: "Legacy", - remoteUrl: "https://gitlab.com/acme/legacy.git", - }), - ], - }, - ], - }); - - expect(result.projects).toHaveLength(1); - const summary = result.projects[0]; - expect(summary?.projectKey).toBe("legacy-project"); - expect(summary?.projectName).toBe("Legacy"); - expect(summary?.githubUrl).toBeUndefined(); - expect(summary?.hosts).toHaveLength(1); - expect(summary?.hosts[0]?.repoRoot).toBe("/repo/legacy"); - expect(summary?.hosts[0]?.workspaceCount).toBe(1); - }); - - it("surfaces a project with no workspaces yet and gives its host an editable repoRoot", () => { - const result = buildProjects({ - hosts: [ - { - serverId: "local", - serverName: "Local", + serverId: "host-a", + serverName: "Host A", isOnline: true, + projects: [descriptor("prj_a", "local-a", "/a/app")], workspaces: [], - emptyProjects: [ - { - projectId: "/repo/fresh", - projectDisplayName: "fresh", - projectCustomName: null, - projectRootPath: "/repo/fresh", - projectKind: "git", - }, - ], }, ], }); - expect(result.projects).toHaveLength(1); - const summary = result.projects[0]; - expect(summary?.projectKey).toBe("/repo/fresh"); - expect(summary?.totalWorkspaceCount).toBe(0); - expect(summary?.hosts).toHaveLength(1); - // repoRoot must be non-empty or the project settings screen treats the host - // as non-editable and shows its empty state. - expect(summary?.hosts[0]?.repoRoot).toBe("/repo/fresh"); - expect(summary?.hosts[0]?.workspaceCount).toBe(0); + expect(result.projects[0]).toMatchObject({ + totalWorkspaceCount: 0, + hosts: [{ projectId: "prj_a", repoRoot: "/a/app" }], + }); + }); + + test("looks up a grouped project by host-local identity", () => { + const project = buildProjects({ + hosts: [ + { + serverId: "host-a", + serverName: "Host A", + isOnline: true, + projects: [descriptor("prj_a", "shared", "/a/app")], + workspaces: [], + }, + ], + }).projects[0]; + + expect(getProjectSummaryForHostProject(project ? [project] : [], "host-a", "prj_a")).toBe( + project, + ); }); }); diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index df2f197611..1e1f48a8b6 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -1,5 +1,5 @@ -import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; -import { buildHostProjectList, type HostProjectListItem } from "@/projects/host-project-model"; +import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; +import type { HostProjectListItem } from "@/projects/host-project-model"; import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; export interface WorkspaceSummary { @@ -14,6 +14,9 @@ export interface WorkspaceSummary { export interface ProjectHostEntry { serverId: string; + projectId: string; + projectName: string; + projectCustomName: string | null; serverName: string; isOnline: boolean; repoRoot: string; @@ -39,8 +42,7 @@ export interface ProjectHost { serverName: string; isOnline: boolean; workspaces: WorkspaceDescriptor[]; - /** Project parents with no active workspaces, so they still surface as projects. */ - emptyProjects?: EmptyProjectDescriptor[]; + projects: ProjectDescriptor[]; } export interface BuildProjectsInput { @@ -51,10 +53,35 @@ export interface BuildProjectsResult { projects: ProjectSummary[]; } -const GITHUB_PROJECT_KEY_PATTERN = /^remote:github\.com\/([^/]+)\/([^/]+)$/; +export function getProjectSummaryForHostProject( + projects: readonly ProjectSummary[], + serverId: string, + projectId: string, +): ProjectSummary | undefined { + for (const project of projects) { + for (const host of project.hosts) { + if (host.serverId === serverId && host.projectId === projectId) return project; + } + } + return undefined; +} + +export function getProjectHostEntry( + project: ProjectSummary | undefined, + serverId: string, + projectId?: string, +): ProjectHostEntry | undefined { + for (const host of project?.hosts ?? []) { + if (host.serverId === serverId && (!projectId || host.projectId === projectId)) return host; + } + return undefined; +} interface HostGroup { serverId: string; + projectId: string; + projectName: string; + projectCustomName: string | null; serverName: string; isOnline: boolean; workspaces: WorkspaceDescriptor[]; @@ -70,50 +97,32 @@ interface ProjectGroup { hostsByServerId: Map; } -function findProjectCustomName( - workspaces: WorkspaceDescriptor[], +function findProjectMetadata( + host: ProjectHost, projectKey: string, -): { customName: string; displayName: string } | null { - for (const workspace of workspaces) { - if (workspace.projectId === projectKey && workspace.projectCustomName) { +): { customName: string | null; displayName: string } | null { + for (const project of host.projects) { + if (project.projectId === projectKey) { return { - customName: workspace.projectCustomName, - displayName: workspace.projectDisplayName, + customName: project.projectCustomName ?? null, + displayName: project.projectDisplayName, }; } } return null; } -function buildHostProjectEntries(host: ProjectHost): HostProjectListItem[] { - return buildHostProjectList({ - projects: buildWorkspaceStructureProjects({ - sessions: [ - { - serverId: host.serverId, - workspaces: host.workspaces, - emptyProjects: host.emptyProjects, - }, - ], - }), +function buildHostProjectEntries(hosts: ProjectHost[]): HostProjectListItem[] { + return buildWorkspaceStructureProjects({ + sessions: hosts.map((host) => ({ + serverId: host.serverId, + projects: host.projects, + workspaces: host.workspaces, + })), }); } -function deriveGithubUrl(projectKey: string): string | undefined { - const match = projectKey.match(GITHUB_PROJECT_KEY_PATTERN); - if (!match) { - return undefined; - } - return `https://github.com/${match[1]}/${match[2]}`; -} - function resolveHostRepoRoot(group: HostGroup): string { - for (const workspace of group.workspaces) { - const mainRepoRoot = workspace.project?.checkout.mainRepoRoot; - if (mainRepoRoot) { - return mainRepoRoot; - } - } return group.workspaces[0]?.projectRootPath ?? group.fallbackRepoRoot; } @@ -137,6 +146,9 @@ function toHostEntry(group: HostGroup): ProjectHostEntry { group.workspaces[0]; return { serverId: group.serverId, + projectId: group.projectId, + projectName: group.projectName, + projectCustomName: group.projectCustomName, serverName: group.serverName, isOnline: group.isOnline, repoRoot, @@ -167,53 +179,81 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary { totalWorkspaceCount, hostCount: hosts.length, onlineHostCount, - githubUrl: deriveGithubUrl(draft.projectKey), }; } +function addHostProjects( + groups: Map, + host: ProjectHost, + hostProjects: HostProjectListItem[], +): void { + const repoRootByProjectId = new Map( + host.projects.map((project) => [project.projectId, project.projectRootPath]), + ); + + for (const hostProject of hostProjects) { + const placement = hostProject.hosts.find((entry) => entry.serverId === host.serverId); + if (!placement) continue; + const projectId = placement.projectId; + const customName = findProjectMetadata(host, projectId); + let group = groups.get(hostProject.projectKey); + if (!group) { + group = { + projectKey: hostProject.projectKey, + projectName: customName?.displayName ?? hostProject.projectName, + projectCustomName: customName?.customName ?? null, + hostsByServerId: new Map(), + }; + groups.set(hostProject.projectKey, group); + } else if (customName?.customName && !group.projectCustomName) { + group.projectCustomName = customName.customName; + group.projectName = customName.displayName; + } + + if (!group.hostsByServerId.has(host.serverId)) { + group.hostsByServerId.set(host.serverId, { + serverId: host.serverId, + projectId, + projectName: customName?.displayName ?? hostProject.projectName, + projectCustomName: customName?.customName ?? null, + serverName: host.serverName, + isOnline: host.isOnline, + workspaces: [], + fallbackRepoRoot: repoRootByProjectId.get(projectId) ?? "", + }); + } + } +} + +function attachHostWorkspaces( + groups: Map, + host: ProjectHost, + hostProjects: HostProjectListItem[], +): void { + const projectKeyByProjectId = new Map( + hostProjects.flatMap((project) => + project.hosts + .filter((placement) => placement.serverId === host.serverId && placement.projectId) + .map((placement) => [placement.projectId!, project.projectKey] as const), + ), + ); + + for (const workspace of host.workspaces) { + const key = projectKeyByProjectId.get(workspace.projectId); + if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace); + } +} + export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { const groups = new Map(); + const projectEntries = buildHostProjectEntries(input.hosts); for (const host of input.hosts) { - const emptyRepoRootByProjectKey = new Map(); - for (const emptyProject of host.emptyProjects ?? []) { - emptyRepoRootByProjectKey.set(emptyProject.projectId, emptyProject.projectRootPath); - } - - const hostProjects = buildHostProjectEntries(host); - for (const hostProject of hostProjects) { - const customName = findProjectCustomName(host.workspaces, hostProject.projectKey); - let group = groups.get(hostProject.projectKey); - if (!group) { - group = { - projectKey: hostProject.projectKey, - projectName: customName?.displayName ?? hostProject.projectName, - projectCustomName: customName?.customName ?? null, - hostsByServerId: new Map(), - }; - groups.set(hostProject.projectKey, group); - } else if (customName && !group.projectCustomName) { - group.projectCustomName = customName.customName; - group.projectName = customName.displayName; - } - - if (!group.hostsByServerId.has(host.serverId)) { - group.hostsByServerId.set(host.serverId, { - serverId: host.serverId, - serverName: host.serverName, - isOnline: host.isOnline, - workspaces: [], - fallbackRepoRoot: emptyRepoRootByProjectKey.get(hostProject.projectKey) ?? "", - }); - } - } - - for (const workspace of host.workspaces) { - const group = groups.get(workspace.projectId); - const hostGroup = group?.hostsByServerId.get(host.serverId); - if (!hostGroup) continue; - hostGroup.workspaces.push(workspace); - } + const hostProjects = projectEntries.filter((project) => + project.hosts.some((placement) => placement.serverId === host.serverId), + ); + addHostProjects(groups, host, hostProjects); + attachHostWorkspaces(groups, host, hostProjects); } const projects = Array.from(groups.values()).map(toProjectSummary); diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 37548071e7..a4054fa8c3 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -6,6 +6,7 @@ import type { import { buildSidebarProjectRowModel, resolveSidebarProjectIconTarget, + resolveSidebarProjectLocalPath, } from "./sidebar-project-row-model"; function workspace(overrides: Partial = {}): SidebarWorkspaceEntry { @@ -34,18 +35,26 @@ function workspace(overrides: Partial = {}): SidebarWorks }; } -function project(overrides: Partial = {}): SidebarProjectEntry { +type ProjectOverrides = Omit, "hosts"> & { + hosts?: Array & { projectId?: string }>; +}; + +function project(overrides: ProjectOverrides = {}): SidebarProjectEntry { const projectKind = overrides.projectKind ?? "git"; + const hosts = Array.from( + overrides.hosts ?? [ + { serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" }, + ], + (host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }), + ); return { projectKey: "project-1", projectName: "paseo", projectKind, iconWorkingDir: "/repo", - hosts: overrides.hosts ?? [ - { serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" }, - ], workspaces: [workspace()], ...overrides, + hosts, }; } @@ -80,7 +89,7 @@ describe("buildSidebarProjectRowModel", () => { chevron: "expand", trailingAction: { kind: "new_workspace", - target: { serverId: "srv", iconWorkingDir: "/repo" }, + target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" }, }, }); }); @@ -94,7 +103,7 @@ describe("buildSidebarProjectRowModel", () => { expect(result.trailingAction).toEqual({ kind: "new_workspace", - target: { serverId: "srv", iconWorkingDir: "/repo" }, + target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" }, }); }); @@ -117,7 +126,7 @@ describe("buildSidebarProjectRowModel", () => { expect(result.trailingAction).toEqual({ kind: "new_workspace", - target: { serverId: "srv", iconWorkingDir: "/repo" }, + target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" }, }); }); @@ -178,7 +187,7 @@ describe("buildSidebarProjectRowModel", () => { chevron: "expand", trailingAction: { kind: "new_workspace", - target: { serverId: "srv", iconWorkingDir: "/repo" }, + target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" }, }, }); }); @@ -193,7 +202,24 @@ describe("buildSidebarProjectRowModel", () => { }), ); - expect(iconTarget).toEqual({ serverId: "host-b", iconWorkingDir: "/repo/b" }); + expect(iconTarget).toEqual({ + serverId: "host-b", + projectId: "project-host-b", + iconWorkingDir: "/repo/b", + }); + }); + + it("resolves desktop file actions from the local project placement", () => { + const groupedProject = project({ + iconWorkingDir: "/remote/repo", + hosts: [ + { serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true }, + { serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true }, + ], + }); + + expect(resolveSidebarProjectLocalPath(groupedProject, "local")).toBe("/local/repo"); + expect(resolveSidebarProjectLocalPath(groupedProject, "missing")).toBe(""); }); it("renders an empty project as an expandable section", () => { @@ -207,7 +233,7 @@ describe("buildSidebarProjectRowModel", () => { chevron: "collapse", trailingAction: { kind: "new_workspace", - target: { serverId: "srv", iconWorkingDir: "/repo" }, + target: { serverId: "srv", projectId: "project-srv", iconWorkingDir: "/repo" }, }, }); }); diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index ee03b67677..d9566198fb 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -2,6 +2,7 @@ import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; export interface SidebarProjectHostTarget { serverId: string; + projectId: string; iconWorkingDir: string; } @@ -18,16 +19,16 @@ export interface SidebarProjectSectionRowModel { export type SidebarProjectRowModel = SidebarProjectSectionRowModel; const EMPTY_MULTIPLICITY_MAP: ReadonlyMap = new Map(); - function hostTarget(input: { serverId: string; + projectId: string; iconWorkingDir: string; }): SidebarProjectHostTarget | null { const iconWorkingDir = input.iconWorkingDir.trim(); if (!input.serverId || !iconWorkingDir) { return null; } - return { serverId: input.serverId, iconWorkingDir }; + return { serverId: input.serverId, projectId: input.projectId, iconWorkingDir }; } export function resolveSidebarProjectIconTarget( @@ -42,6 +43,14 @@ export function resolveSidebarProjectIconTarget( return null; } +export function resolveSidebarProjectLocalPath( + project: SidebarProjectEntry, + localServerId: string | null, +): string { + if (!localServerId) return ""; + return project.hosts.find((host) => host.serverId === localServerId)?.iconWorkingDir.trim() ?? ""; +} + // A project can host a brand-new workspace on a host when that host can create a // git worktree (git projects) OR the host supports running multiple independent // workspaces per directory (`workspaceMultiplicity`), which is what lets non-git @@ -57,9 +66,7 @@ function resolveNewWorkspaceTarget( continue; } const target = hostTarget(host); - if (target) { - return target; - } + if (target) return target; } return null; } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 4b853de308..d5f11d779d 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -53,6 +53,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb hosts: [ { serverId: workspaces[0]?.serverId ?? "s1", + projectId: projectKey, iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", canCreateWorktree: true, }, diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts index 43f4ef563d..7f50c4b57a 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility"; +import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; import { applyLegacyDaemonWorkspaceOwnership, backfillLegacyDaemonWorkspaceDirectoryIfEmpty, @@ -10,6 +11,17 @@ import { const SERVER_ID = "srv_legacy"; +function legacyProjectFromWorkspace(workspace: WorkspaceDescriptor) { + return { + projectId: workspace.projectId, + projectKey: null, + projectDisplayName: workspace.projectDisplayName, + projectCustomName: workspace.projectCustomName ?? null, + projectRootPath: workspace.projectRootPath, + projectKind: workspace.projectKind, + }; +} + function legacyAgent(input: { id: string; cwd: string; @@ -118,6 +130,38 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => { ]); }); + it("keeps matching legacy path projects separate across hosts", () => { + const first = buildLegacyDaemonWorkspaceSnapshot({ + serverId: "host-a", + entries: [legacyAgent({ id: "agent-a", cwd: "/repo/app" })], + }); + const second = buildLegacyDaemonWorkspaceSnapshot({ + serverId: "host-b", + entries: [legacyAgent({ id: "agent-b", cwd: "/repo/app" })], + }); + + const projects = buildWorkspaceStructureProjects({ + sessions: [ + { + serverId: "host-a", + projects: Array.from(first.workspaces.values(), legacyProjectFromWorkspace), + workspaces: first.workspaces.values(), + }, + { + serverId: "host-b", + projects: Array.from(second.workspaces.values(), legacyProjectFromWorkspace), + workspaces: second.workspaces.values(), + }, + ], + }); + + expect(projects).toHaveLength(2); + expect(projects.map((project) => project.hosts[0]?.serverId).sort()).toEqual([ + "host-a", + "host-b", + ]); + }); + it("keeps old-daemon agent updates attached to the path-backed workspace", () => { const snapshot = buildLegacyDaemonWorkspaceSnapshot({ serverId: SERVER_ID, @@ -185,7 +229,7 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => { client, serverId: SERVER_ID, workspaces: new Map(), - emptyProjects: new Map(), + projects: new Map(), isCancelled: () => cancelled, }); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 820e373688..6de82a3f3e 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -7,7 +7,12 @@ import { deriveAgentStateBucket, getWorkspaceStateBucketPriority, } from "@getpaseo/protocol/agent-state-bucket"; -import type { Agent, DaemonServerInfo, WorkspaceDescriptor } from "@/stores/session-store"; +import type { + Agent, + DaemonServerInfo, + ProjectDescriptor, + WorkspaceDescriptor, +} from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { buildAgentDirectoryState, @@ -33,7 +38,7 @@ interface LegacyDaemonWorkspaceBackfillInput { client: Pick; serverId: string; workspaces: ReadonlyMap; - emptyProjects: ReadonlyMap; + projects: ReadonlyMap; isCancelled?: () => boolean; } @@ -97,7 +102,7 @@ export async function fetchLegacyDaemonWorkspaceDirectory(input: { export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty( input: LegacyDaemonWorkspaceBackfillInput, ): Promise { - if (input.workspaces.size > 0 || input.emptyProjects.size > 0) { + if (input.workspaces.size > 0 || input.projects.size > 0) { return false; } const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo; @@ -205,11 +210,25 @@ export function replaceLegacyDaemonWorkspaceDirectory(input: { const workspaces = buildLegacyWorkspaces(entries); const store = useSessionStore.getState(); store.setWorkspaces(input.serverId, workspaces); - store.setEmptyProjects(input.serverId, []); + store.setProjects( + input.serverId, + Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), + ); store.setHasHydratedWorkspaces(input.serverId, true); return { agents, workspaces }; } +function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { + return { + projectId: workspace.projectId, + projectKey: null, + projectDisplayName: workspace.projectDisplayName, + projectCustomName: workspace.projectCustomName ?? null, + projectRootPath: workspace.projectRootPath, + projectKind: workspace.projectKind, + }; +} + function readFetchAgentsHasMore( pageInfo: Awaited>["pageInfo"], ): boolean { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index ecfd68a472..8b99449f25 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -678,6 +678,10 @@ export type FetchWorkspacesOptions = Omit["payload"]; export interface CreateChatRoomOptions { name: string; purpose?: string | null; @@ -2092,6 +2096,24 @@ export class DaemonClient { }); } + async listProjects(requestId?: string): Promise { + const resolvedRequestId = this.createRequestId(requestId); + const message = SessionInboundMessageSchema.parse({ + type: "project.list.request", + requestId: resolvedRequestId, + }); + return this.sendRequest({ + requestId: resolvedRequestId, + message, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "project.list.response") return null; + if (msg.payload.requestId !== resolvedRequestId) return null; + return msg.payload; + }, + }); + } + async openProject(cwd: string, requestId?: string): Promise { return this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/protocol/src/git-remote.test.ts b/packages/protocol/src/git-remote.test.ts index 26d70f3dbf..54d260eff5 100644 --- a/packages/protocol/src/git-remote.test.ts +++ b/packages/protocol/src/git-remote.test.ts @@ -48,4 +48,19 @@ describe("parseGitRemoteLocation port", () => { it("has no port for an scp-form remote", () => { expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined(); }); + + it("omits an explicitly written default port", () => { + expect(parseGitRemoteLocation("ssh://git@github.com:22/acme/repo.git")).toEqual( + parseGitRemoteLocation("ssh://git@github.com/acme/repo.git"), + ); + }); + + it("reads a scheme remote as a URL rather than scp form", () => { + expect(parseGitRemoteLocation("ssh://git@example.com:2222/acme/repo.git")).toEqual({ + transport: "ssh", + host: "example.com", + port: "2222", + path: "acme/repo", + }); + }); }); diff --git a/packages/protocol/src/git-remote.ts b/packages/protocol/src/git-remote.ts index bc88ba0b72..56f1363a96 100644 --- a/packages/protocol/src/git-remote.ts +++ b/packages/protocol/src/git-remote.ts @@ -8,6 +8,12 @@ const TRANSPORT_BY_PROTOCOL: Record = { "ssh:": "ssh", }; +const DEFAULT_PORT_BY_PROTOCOL: Record = { + "https:": "443", + "http:": "80", + "ssh:": "22", +}; + export interface GitRemoteLocation { transport: "scp" | "ssh" | "http" | "https"; host: string; @@ -50,7 +56,9 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n const trimmed = remoteUrl.trim(); if (!trimmed) return null; - const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u); + // scp form has no scheme. Testing it first would match `ssh://git@host:22/x` + // — `[^@]+` happily eats `ssh://git` — and swallow the port into the path. + const scpLike = trimmed.includes("://") ? null : trimmed.match(/^[^@]+@([^:]+):(.+)$/u); if (scpLike) { const host = normalizeHost(scpLike[1] ?? ""); const path = normalizeRemotePath(scpLike[2] ?? ""); @@ -78,7 +86,10 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n const normalizedPath = normalizeRemotePath(path); if (!isValidRemoteHost(host) || !normalizedPath) return null; - return { transport, host, port: parsed.port || undefined, path: normalizedPath }; + const protocol = parsed.protocol.toLowerCase(); + const port = + parsed.port && parsed.port !== DEFAULT_PORT_BY_PROTOCOL[protocol] ? parsed.port : undefined; + return { transport, host, port, path: normalizedPath }; } export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 7c1e2bc06d..af44e09f90 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1072,6 +1072,11 @@ export const FetchWorkspacesRequestMessageSchema = z.object({ .optional(), }); +export const ProjectListRequestMessageSchema = z.object({ + type: z.literal("project.list.request"), + requestId: z.string(), +}); + export const FetchAgentHistoryRequestMessageSchema = z.object({ type: z.literal("fetch_agent_history_request"), requestId: z.string(), @@ -2445,6 +2450,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentHistoryRequestMessageSchema, FetchRecentProviderSessionsRequestMessageSchema, FetchWorkspacesRequestMessageSchema, + ProjectListRequestMessageSchema, FetchAgentRequestMessageSchema, DeleteAgentRequestMessageSchema, ArchiveAgentRequestMessageSchema, @@ -2814,6 +2820,8 @@ export const ServerInfoStatusPayloadSchema = z workspaceGithubRepositorySearch: z.boolean().optional(), // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. projectCreateDirectory: z.boolean().optional(), + // COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4. + projectList: z.boolean().optional(), // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. commitsList: z.boolean().optional(), // COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23. @@ -3232,6 +3240,8 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({ // workspace is archived. export const WorkspaceProjectDescriptorPayloadSchema = z.object({ projectId: z.string(), + // COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28. + projectKey: z.string().optional(), projectDisplayName: z.string(), projectCustomName: z.string().nullable().optional(), projectRootPath: z.string(), @@ -3288,6 +3298,14 @@ export const ProjectUpdateMessageSchema = z.object({ ]), }); +export const ProjectListResponseMessageSchema = z.object({ + type: z.literal("project.list.response"), + payload: z.object({ + requestId: z.string(), + projects: z.array(WorkspaceProjectDescriptorPayloadSchema), + }), +}); + export const ScriptStatusUpdateMessageSchema = z.object({ type: z.literal("script_status_update"), payload: z.object({ @@ -5185,6 +5203,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentUpdateMessageSchema, WorkspaceUpdateMessageSchema, ProjectUpdateMessageSchema, + ProjectListResponseMessageSchema, ScriptStatusUpdateMessageSchema, WorkspaceSetupProgressMessageSchema, WorkspaceSetupStatusResponseMessageSchema, @@ -5363,6 +5382,7 @@ export type WorkspaceDescriptorPayload = z.infer; +export type ProjectListResponseMessage = z.infer; export type WorkspaceScriptLifecycle = z.infer; export type WorkspaceScriptHealth = z.infer; export type WorkspaceScriptPayload = z.infer; @@ -5508,6 +5528,7 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer< typeof FetchRecentProviderSessionsRequestMessageSchema >; export type FetchWorkspacesRequestMessage = z.infer; +export type ProjectListRequestMessage = z.infer; export type FetchAgentRequestMessage = z.infer; export type AgentForkContextRequestMessage = z.infer; export type SendAgentMessageRequest = z.infer; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 84c9df422f..b29b0a39f5 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -2564,6 +2564,7 @@ describe("create_agent MCP tool", () => { providerSnapshotManager: createOpenCodeManager().manager, projectRegistry: { get: async (projectId) => (projectId === project.projectId ? project : null), + list: async () => [project], }, createPaseoWorktree, logger, diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 906fd16a23..e681eb521f 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -108,7 +108,7 @@ export interface PaseoToolHostDependencies { archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; workspaceRegistry?: Pick; - projectRegistry?: Pick; + projectRegistry?: Pick; createDirectoryWorkspace?: ( cwd: string, title?: string | null, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2a1005cb75..09ff21faad 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -793,6 +793,7 @@ export async function createPaseoDaemon( }, }); const workspaceProvisioning = createWorkspaceProvisioningService({ + serverId, projectRegistry, workspaceRegistry, workspaceGitService, @@ -829,6 +830,7 @@ export async function createPaseoDaemon( await agentStorage.initialize(); logger.info({ elapsed: elapsed() }, "Agent storage initialized"); await bootstrapWorkspaceRegistries({ + serverId, paseoHome: config.paseoHome, agentStorage, projectRegistry, @@ -842,6 +844,7 @@ export async function createPaseoDaemon( releaseWorkspaceServicePortPlan(workspaceId); }; const workspaceReconciliation = new WorkspaceReconciliationService({ + serverId, projectRegistry, workspaceRegistry, logger, @@ -857,7 +860,7 @@ export async function createPaseoDaemon( }, }); await workspaceReconciliation.start(); - void workspaceReconciliation.runOnce().catch((error) => { + void workspaceReconciliation.reconcileNow().catch((error) => { logger.warn({ err: error }, "Initial workspace reconciliation failed"); }); await chatService.initialize(); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 13bb5ec796..5fec6bad05 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -23,6 +23,7 @@ import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js"; import { isPlatform } from "../test-utils/platform.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js"; +import { deriveProjectKey } from "./project-key.js"; const cleanupPaths: string[] = []; @@ -73,7 +74,16 @@ test("creates a worktree and registers it in the source workspace project withou expect(result.workspace.baseBranch).toBe("main"); expect(result.workspace.title).toBe("Feature One"); expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled(); - expect(deps.projects.get(sourceProject.projectId)).toEqual(sourceProject); + expect(deps.projects.get(sourceProject.projectId)).toEqual({ + ...sourceProject, + projectKey: deriveProjectKey({ + rootPath: result.repoRoot, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), + updatedAt: expect.any(String), + }); expect(events).toEqual([`workspace:${result.workspace.workspaceId}`]); }); @@ -436,7 +446,16 @@ test("an explicit project FK remains unchanged when its worktree comes from anot ); expect(result.workspace.projectId).toBe(project.projectId); - expect(deps.projects.get(project.projectId)).toEqual(project); + expect(deps.projects.get(project.projectId)).toEqual({ + ...project, + projectKey: deriveProjectKey({ + rootPath: project.rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), + updatedAt: expect.any(String), + }); }); // POSIX-only: Windows git worktree paths need separate canonicalization coverage. diff --git a/packages/server/src/server/project-key.test.ts b/packages/server/src/server/project-key.test.ts new file mode 100644 index 0000000000..ccfa65490e --- /dev/null +++ b/packages/server/src/server/project-key.test.ts @@ -0,0 +1,141 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { deriveProjectGroupingDisplayName, deriveProjectKey } from "./project-key.js"; + +describe("deriveProjectKey", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectKey({ rootPath, remoteUrl, worktreeRoot: rootPath, mainRepoRoot: null }); + + test.each([ + "https://github.com/getpaseo/paseo.git", + "ssh://git@github.com/getpaseo/paseo.git", + "git@github.com:getpaseo/paseo.git", + ])("normalizes common remote form %s", (remoteUrl) => { + expect(derive(remoteUrl)).toBe("remote:github.com/getpaseo/paseo"); + }); + + test("normalizes GitHub casing", () => { + expect(derive("git@github.com:GetPaseo/Paseo.git")).toBe("remote:github.com/getpaseo/paseo"); + }); + + test("preserves self-hosted paths and explicit ports", () => { + expect(derive("https://git.example.com:8443/Team/App.git")).toBe( + "remote:git.example.com:8443/Team/App", + ); + }); + + test("groups an SSH remote whether or not its default port is spelled out", () => { + expect(derive("ssh://git@github.com:22/getpaseo/paseo.git")).toBe( + derive("ssh://git@github.com/getpaseo/paseo.git"), + ); + }); + + test("keeps projects without a remote host-local", () => { + const hostA = deriveProjectKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + serverId: "host-a", + }); + const hostB = deriveProjectKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + serverId: "host-b", + }); + expect(hostA).not.toBe(hostB); + }); + + test("uses one host-local key for two spellings of the same directory", () => { + const tempRoot = mkdtempSync(path.join(tmpdir(), "project-key-identity-")); + const projectRoot = path.join(tempRoot, "project"); + const alternateRoot = path.join(tempRoot, "project-alias"); + const deriveLocalProject = (localRootPath: string) => + deriveProjectKey({ + rootPath: localRootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + serverId: "host-a", + }); + + try { + mkdirSync(projectRoot); + symlinkSync(projectRoot, alternateRoot, process.platform === "win32" ? "junction" : "dir"); + expect(deriveLocalProject(alternateRoot)).toBe(deriveLocalProject(projectRoot)); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + test("keeps a repository root distinct from one of its subprojects", () => { + const worktreeRoot = path.resolve("host-a", "repo"); + const remoteUrl = "git@github.com:getpaseo/paseo.git"; + + expect( + deriveProjectKey({ rootPath: worktreeRoot, remoteUrl, worktreeRoot, mainRepoRoot: null }), + ).toBe("remote:github.com/getpaseo/paseo"); + expect( + deriveProjectKey({ + rootPath: path.join(worktreeRoot, "packages", "app"), + remoteUrl, + worktreeRoot, + mainRepoRoot: null, + }), + ).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app"); + }); + + test("groups the same subproject across different absolute checkout roots", () => { + const remoteUrl = "git@github.com:getpaseo/paseo.git"; + const deriveSubproject = (worktreeRoot: string) => + deriveProjectKey({ + rootPath: path.join(worktreeRoot, "packages", "app"), + remoteUrl, + worktreeRoot, + mainRepoRoot: null, + }); + + expect(deriveSubproject(path.resolve("host-a", "repo"))).toBe( + deriveSubproject(path.resolve("host-b", "different", "repo")), + ); + }); +}); + +describe("deriveProjectGroupingDisplayName", () => { + test("uses the owner and repository from a remote", () => { + expect( + deriveProjectGroupingDisplayName({ + rootPath: path.resolve("repo"), + remoteUrl: "git@github.com:getpaseo/paseo.git", + worktreeRoot: path.resolve("repo"), + }), + ).toBe("getpaseo/paseo"); + }); + + test("uses the selected directory name without a remote", () => { + expect( + deriveProjectGroupingDisplayName({ + rootPath: path.resolve("acme", "app"), + remoteUrl: null, + worktreeRoot: null, + }), + ).toBe("app"); + }); + + test("uses the selected subproject directory name instead of the repository remote", () => { + const worktreeRoot = path.resolve("acme", "repo"); + + expect( + deriveProjectGroupingDisplayName({ + rootPath: path.join(worktreeRoot, "packages", "server"), + remoteUrl: "git@github.com:acme/repo.git", + worktreeRoot, + }), + ).toBe("server"); + }); +}); diff --git a/packages/server/src/server/project-key.ts b/packages/server/src/server/project-key.ts new file mode 100644 index 0000000000..2f5c1f79cb --- /dev/null +++ b/packages/server/src/server/project-key.ts @@ -0,0 +1,57 @@ +import { resolve, win32 } from "node:path"; +import { isGitHubHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; +import { getRealpathAwareRelativePath, normalizePathForIdentity } from "../utils/path.js"; + +/** Persisted opaque key used to join the same remote across hosts. */ +export function deriveProjectKey(input: { + rootPath: string; + remoteUrl: string | null; + worktreeRoot: string | null; + mainRepoRoot: string | null; + serverId?: string; +}): string { + const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null; + const selectedPath = input.worktreeRoot + ? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath) || null + : null; + if (remote) { + const host = remote.port ? `${remote.host}:${remote.port}` : remote.host; + const path = isGitHubHost(remote.host) ? remote.path.toLowerCase() : remote.path; + const remoteKey = `remote:${host}/${path}`; + return selectedPath ? `${remoteKey}#subdir:${selectedPath.replaceAll("\\", "/")}` : remoteKey; + } + + const localPathParts = [ + selectedPath && input.mainRepoRoot ? input.mainRepoRoot : input.rootPath, + selectedPath && input.mainRepoRoot ? selectedPath : "", + ]; + const resolvedLocalPath = localPathParts.some(looksLikeWindowsPath) + ? win32.resolve(...localPathParts) + : resolve(...localPathParts); + const localPath = normalizePathForIdentity(resolvedLocalPath); + return input.serverId ? `host:${input.serverId}:${localPath}` : localPath; +} + +export function deriveProjectGroupingDisplayName(input: { + rootPath: string; + remoteUrl: string | null; + worktreeRoot: string | null; +}): string { + const selectedPath = input.worktreeRoot + ? getRealpathAwareRelativePath(input.worktreeRoot, input.rootPath) + : null; + if (selectedPath) return lastPathSegment(input.rootPath); + + const remote = input.remoteUrl ? parseGitRemoteLocation(input.remoteUrl) : null; + if (!remote) return lastPathSegment(input.rootPath); + return remote.path.split("/").filter(Boolean).slice(-2).join("/") || input.rootPath; +} + +function lastPathSegment(inputPath: string): string { + const segments = inputPath.split(/[\\/]/u).filter(Boolean); + return segments[segments.length - 1] ?? inputPath; +} + +function looksLikeWindowsPath(value: string): boolean { + return /^[a-zA-Z]:[\\/]/u.test(value) || /^[/\\]{2}[^/\\]+[/\\][^/\\]+/u.test(value); +} diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 9b9701ecb9..b987c7c5de 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -25,6 +25,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentManagerEvent } from "./agent/agent-manager.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { createPersistedProjectRecord } from "./workspace-registry.js"; +import { deriveProjectKey } from "./project-key.js"; import type { SessionOptions } from "./session.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js"; import { @@ -649,6 +650,12 @@ describe("project command-center RPCs", () => { rootPath: directoryPath, kind: "non_git", displayName: "new-project", + projectKey: deriveProjectKey({ + rootPath: directoryPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), timestamp: expect.any(String), }); expect(messages).toEqual([ @@ -3416,7 +3423,7 @@ describe("session workspace descriptors", () => { const messages: unknown[] = []; const workspace = { workspaceId: "ws-gh", - projectId: "remote:github.com/acme/app", + projectId: "prj_app", cwd: "/repo/app", kind: "local_checkout" as const, displayName: "app", @@ -3424,7 +3431,8 @@ describe("session workspace descriptors", () => { archivedAt: null, }; const project = { - projectId: "remote:github.com/acme/app", + projectId: "prj_app", + projectKey: "remote:github.com/acme/app", rootPath: "/repo/app", kind: "git" as const, displayName: "acme/app", @@ -3435,7 +3443,10 @@ describe("session workspace descriptors", () => { const session = createSessionForTest({ messages, workspaceRegistry: { get: vi.fn(), list: vi.fn().mockResolvedValue([workspace]) }, - projectRegistry: { list: vi.fn().mockResolvedValue([project]), get: vi.fn() }, + projectRegistry: { + list: vi.fn().mockResolvedValue([project]), + get: vi.fn().mockResolvedValue(project), + }, workspaceGitService: { getSnapshot: vi.fn(), peekSnapshot: vi.fn(() => @@ -3464,8 +3475,9 @@ describe("session workspace descriptors", () => { entries: [ expect.objectContaining({ id: "ws-gh", + projectId: "prj_app", project: expect.objectContaining({ - projectKey: "remote:github.com/acme/app", + projectKey: "prj_app", projectName: "acme/app", workspaceName: "app", checkout: expect.objectContaining({ @@ -4900,6 +4912,53 @@ test("sends project updates only to capable sockets in a retained session", () = ]); }); +test("project.list returns every active project descriptor", async () => { + const messages: SessionOutboundMessage[] = []; + const active = createPersistedProjectRecord({ + projectId: "project-active", + projectKey: "remote:github.com/acme/app", + rootPath: "/tmp/project-active", + kind: "git", + displayName: "acme/app", + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:00:00.000Z", + }); + const archived = createPersistedProjectRecord({ + projectId: "project-archived", + rootPath: "/tmp/project-archived", + kind: "non_git", + displayName: "archived", + createdAt: "2026-07-17T00:00:00.000Z", + updatedAt: "2026-07-17T00:00:00.000Z", + archivedAt: "2026-07-18T00:00:00.000Z", + }); + const session = createSessionForTest({ + messages, + projectRegistry: { list: vi.fn().mockResolvedValue([active, archived]) }, + }); + + await session.handleMessage({ type: "project.list.request", requestId: "projects-1" }); + + expect(messages).toEqual([ + { + type: "project.list.response", + payload: { + requestId: "projects-1", + projects: [ + { + projectId: "project-active", + projectKey: "remote:github.com/acme/app", + projectDisplayName: "acme/app", + projectCustomName: null, + projectRootPath: "/tmp/project-active", + projectKind: "git", + }, + ], + }, + }, + ]); +}); + describe("agent config setters", () => { function liveAgentManager(overrides: { [K in keyof SessionOptions["agentManager"]]?: unknown }): { [K in keyof SessionOptions["agentManager"]]?: unknown; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2682979fe1..d010a47a1d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -739,6 +739,7 @@ export class Session { }); this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ + serverId, workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, workspaceGitService: this.workspaceGitService, @@ -2073,6 +2074,8 @@ export class Session { switch (msg.type) { case "fetch_workspaces_request": return this.handleFetchWorkspacesRequest(msg); + case "project.list.request": + return this.handleProjectListRequest(msg.requestId); case "paseo_worktree_list_request": return this.handlePaseoWorktreeListRequest(msg); case "paseo_worktree_archive_request": @@ -2631,7 +2634,7 @@ export class Session { // resolved name lands in the UI immediately. const workspaces = await this.workspaceRegistry.list(); const affectedWorkspaceIds = workspaces - .filter((workspace) => workspace.projectId === projectId) + .filter((workspace) => workspace.projectId === existing.projectId) .map((workspace) => workspace.workspaceId); if (affectedWorkspaceIds.length > 0) { await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds); @@ -2670,8 +2673,10 @@ export class Session { this.sessionLogger.info({ projectId, requestId }, "session: project.remove.request"); try { + const project = await this.projectRegistry.get(projectId); + const resolvedProjectId = project?.projectId ?? projectId; const projectWorkspaces = (await this.workspaceRegistry.list()).filter( - (workspace) => workspace.projectId === projectId, + (workspace) => workspace.projectId === resolvedProjectId, ); const activeWorkspaceIds = projectWorkspaces .filter((workspace) => !workspace.archivedAt) @@ -2699,7 +2704,7 @@ export class Session { removedWorkspaceIds.push(workspaceId); } - await this.projectRegistry.remove(projectId); + await this.projectRegistry.remove(resolvedProjectId); } finally { if (activeWorkspaceIds.length > 0) { this.clearWorkspaceArchiving(activeWorkspaceIds); @@ -4460,6 +4465,7 @@ export class Session { ): WorkspaceProjectDescriptorPayload { return { projectId: project.projectId, + ...(project.projectKey ? { projectKey: project.projectKey } : {}), projectDisplayName: resolveProjectDisplayName(project), projectCustomName: project.customName ?? null, projectRootPath: project.rootPath, @@ -4934,6 +4940,29 @@ export class Session { } } + private async handleProjectListRequest(requestId: string): Promise { + try { + const projects = (await this.projectRegistry.list()) + .filter((project) => !project.archivedAt) + .map((project) => this.buildProjectDescriptor(project)); + this.emit({ + type: "project.list.response", + payload: { requestId, projects }, + }); + } catch (error) { + this.sessionLogger.error({ err: error }, "Failed to handle project.list.request"); + this.emit({ + type: "rpc_error", + payload: { + requestId, + requestType: "project.list.request", + error: error instanceof Error ? error.message : "Failed to list projects", + code: "project_list_failed", + }, + }); + } + } + // Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates` // to decide which pending updates to drop. Captures the status, // statusEnteredAt, and activityAt (parsed to ms) for each workspace entry diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 8a87e5064d..d4ad5d1c88 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -46,6 +46,7 @@ import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js import { WorkspaceAutoName } from "./workspace-auto-name.js"; import type { ForgeService } from "../services/forge-service.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; +import { deriveProjectKey } from "./project-key.js"; import { asSessionLogger, asAgentManager, @@ -819,6 +820,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const session = asTestSession( new Session({ clientId: "test-client", + serverId: "test-server", scopes: ["*"], appVersion: null, onMessage: (message) => emitted.push(message), @@ -870,10 +872,21 @@ test("create_agent_request keeps requested child cwd when grouped under an exist const [createdAgent] = agentManager.listAgents(); expect(createdAgent?.cwd).toBe(child); + const createdWorkspace = await workspaceRegistry.get(createdAgent!.workspaceId!); + expect(createdWorkspace).not.toBeNull(); + await expect(projectRegistry.get(createdWorkspace!.projectId)).resolves.toMatchObject({ + projectKey: deriveProjectKey({ + rootPath: child, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + serverId: "test-server", + }), + }); await expect( session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!), ).resolves.toMatchObject({ - projectKey: expect.stringMatching(/^prj_[0-9a-f]{16}$/), + projectKey: createdWorkspace!.projectId, checkout: { cwd: child }, }); expect(findByType(emitted, "status")?.payload).toMatchObject({ @@ -3405,6 +3418,7 @@ test("project.remove.request archives active workspaces and removes the project }); const project = createPersistedProjectRecord({ projectId: "proj-remove-with-workspace", + projectKey: "remote:github.com/acme/remove-with-workspace", rootPath: REPO_CWD, kind: "git", displayName: "repo", @@ -3710,7 +3724,15 @@ test("create paseo worktree response preserves an explicit non-Git project", asy expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/); expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123")); expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true); - expect(projects.get(explicitProject.projectId)).toEqual(explicitProject); + expect(projects.get(explicitProject.projectId)).toEqual({ + ...explicitProject, + projectKey: deriveProjectKey({ + rootPath: explicitProject.rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), + }); }); test("workspace updates stay scoped to the matching cwd", async () => { @@ -7387,7 +7409,8 @@ test("project.rename.request stores customName and emits an updated workspace de ); const project = createPersistedProjectRecord({ - projectId: "remote:github.com/acme/repo", + projectId: "prj_rename", + projectKey: "remote:github.com/acme/repo", rootPath: REPO_CWD, kind: "git", displayName: "acme/repo", diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 698e2e4511..8c08093f22 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -206,6 +206,10 @@ test("reopening archived exact-root records restores the fresh Git project", asy displayName: "repo", timestamp: ARCHIVED_AT, }); + await projectRegistry.upsert({ + ...project, + projectKey: "remote:github.com/acme/old-repo", + }); const workspace = createPersistedWorkspaceRecord({ workspaceId: "ws-archived-root", projectId: project.projectId, @@ -227,7 +231,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy cwd, isGit: true, currentBranch: "main", - remoteUrl: null, + remoteUrl: "https://github.com/acme/new-repo.git", worktreeRoot: cwd, isPaseoOwnedWorktree: false, mainRepoRoot: null, @@ -244,6 +248,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy }); expect(await projectRegistry.get(project.projectId)).toMatchObject({ kind: "git", + projectKey: "remote:github.com/acme/new-repo", archivedAt: null, }); }); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 98d076ecf8..61a25b96bd 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -14,6 +14,7 @@ import { } from "../../workspace-registry.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; +import { deriveProjectKey } from "../../project-key.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js"; export interface ResolveOrCreateWorkspaceIdInput { @@ -84,12 +85,13 @@ export class WorkspaceProvisioningError extends Error { } export function createWorkspaceProvisioningService(deps: { + serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; workspaceGitService: Pick; logger: Logger; }): WorkspaceProvisioningService { - const { workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; + const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; async function runInImportWorkspace( input: ImportWorkspaceInput, @@ -162,6 +164,13 @@ export function createWorkspaceProvisioningService(deps: { rootPath, kind: checkout.isGit ? "git" : "non_git", displayName: basename(rootPath) || rootPath, + projectKey: deriveProjectKey({ + rootPath, + remoteUrl: checkout.remoteUrl, + worktreeRoot: checkout.worktreeRoot, + mainRepoRoot: checkout.mainRepoRoot, + serverId, + }), timestamp, }); } @@ -256,10 +265,18 @@ export function createWorkspaceProvisioningService(deps: { // Orphaned legacy workspace FKs fall through to exact-root allocation. } + const checkout = await workspaceGitService.getCheckout(input.repoRoot); const project = await projectRegistry.getOrCreateActiveByRoot({ rootPath: input.repoRoot, kind: "git", displayName: basename(input.repoRoot) || input.repoRoot, + projectKey: deriveProjectKey({ + rootPath: input.repoRoot, + remoteUrl: checkout.remoteUrl, + worktreeRoot: checkout.worktreeRoot, + mainRepoRoot: checkout.mainRepoRoot, + serverId, + }), timestamp: new Date().toISOString(), }); return refreshProjectKind(project); @@ -334,8 +351,21 @@ export function createWorkspaceProvisioningService(deps: { ? checkout : await workspaceGitService.getCheckout(project.rootPath); const kind = projectCheckout.isGit ? "git" : "non_git"; - if (project.archivedAt || project.kind !== kind) { - await projectRegistry.upsert({ ...project, kind, archivedAt: null, updatedAt: timestamp }); + const projectKey = deriveProjectKey({ + rootPath: project.rootPath, + remoteUrl: projectCheckout.remoteUrl, + worktreeRoot: projectCheckout.worktreeRoot, + mainRepoRoot: projectCheckout.mainRepoRoot, + serverId, + }); + if (project.archivedAt || project.kind !== kind || project.projectKey !== projectKey) { + await projectRegistry.upsert({ + ...project, + kind, + projectKey, + archivedAt: null, + updatedAt: timestamp, + }); } } if (!next) return workspace; @@ -371,8 +401,20 @@ export function createWorkspaceProvisioningService(deps: { ? workspaceCheckout : await workspaceGitService.getCheckout(project.rootPath); const kind: PersistedProjectRecord["kind"] = projectCheckout.isGit ? "git" : "non_git"; - if (project.kind === kind) return project; - const refreshed = { ...project, kind, updatedAt: new Date().toISOString() }; + const projectKey = deriveProjectKey({ + rootPath: project.rootPath, + remoteUrl: projectCheckout.remoteUrl, + worktreeRoot: projectCheckout.worktreeRoot, + mainRepoRoot: projectCheckout.mainRepoRoot, + serverId, + }); + if (project.kind === kind && project.projectKey === projectKey) return project; + const refreshed = { + ...project, + kind, + projectKey, + updatedAt: new Date().toISOString(), + }; await projectRegistry.upsert(refreshed); return refreshed; } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 9501fe8639..9085d0c9b2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -260,6 +260,7 @@ function createNoopProjectRegistry(): ProjectRegistry { rootPath: input.rootPath, kind: input.kind, displayName: input.displayName, + projectKey: input.projectKey ?? null, customName: null, createdAt: input.timestamp, updatedAt: input.timestamp, @@ -1530,6 +1531,8 @@ export class VoiceAssistantWebSocketServer { projectRemove: true, // COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97. projectAdd: true, + // COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4. + projectList: true, // COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105. worktreeRestore: true, // COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105. diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 0e85f0dac5..acb18d692d 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -502,6 +502,7 @@ export class WorkspaceDirectory { ) .map((project) => ({ projectId: project.projectId, + projectKey: project.projectKey ?? undefined, projectDisplayName: resolveProjectDisplayName(project), projectCustomName: project.customName ?? null, projectRootPath: project.rootPath, diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 26559458b7..8acab4b616 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -19,6 +19,16 @@ import { type ReconciliationChange, WorkspaceReconciliationService, } from "./workspace-reconciliation-service.js"; +import { deriveProjectKey } from "./project-key.js"; + +function canonicalLocalProjectKey(rootPath: string): string { + return deriveProjectKey({ + rootPath, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }); +} function createTestRegistries() { const projects = new Map(); @@ -252,8 +262,21 @@ describe("WorkspaceReconciliationService", () => { }); const metadataResult = await service.reconcileGitMetadata(); + const projectKey = deriveProjectKey({ + rootPath: projectRoot, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }); - expect(metadataResult.changesApplied).toEqual([]); + expect(metadataResult.changesApplied).toEqual([ + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { projectKey }, + }, + ]); expect(workspaces.get("w1")?.archivedAt).toBeNull(); const fullResult = await service.runOnce(); @@ -303,8 +326,21 @@ describe("WorkspaceReconciliationService", () => { }), ); const afterGitInit = await service.reconcileGitMetadata(); + const projectKey = deriveProjectKey({ + rootPath: projectRoot, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }); - expect(beforeGitInit.changesApplied).toEqual([]); + expect(beforeGitInit.changesApplied).toEqual([ + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { projectKey }, + }, + ]); expect(afterGitInit.changesApplied).toEqual([ { kind: "project_updated", @@ -386,7 +422,20 @@ describe("WorkspaceReconciliationService", () => { const result = await service.reconcileGitMetadata(); - expect(result.changesApplied).toEqual([]); + expect(result.changesApplied).toEqual([ + { + kind: "project_updated", + projectId: "p1", + directory: projectRoot, + fields: { projectKey: canonicalLocalProjectKey(projectRoot) }, + }, + { + kind: "project_updated", + projectId: "p2", + directory: equivalentProjectRoot, + fields: { projectKey: canonicalLocalProjectKey(projectRoot) }, + }, + ]); expect(git.reads).toEqual([projectRoot, workspaceRoot]); }); @@ -445,7 +494,7 @@ describe("WorkspaceReconciliationService", () => { kind: "project_updated", projectId: "p1", directory: projectRoot, - fields: { kind: "git" }, + fields: { kind: "git", projectKey: canonicalLocalProjectKey(projectRoot) }, }, { kind: "workspace_updated", @@ -462,6 +511,7 @@ describe("WorkspaceReconciliationService", () => { expect(projects.get("p1")).toEqual({ ...originalProject, kind: "git", + projectKey: canonicalLocalProjectKey(projectRoot), updatedAt: expect.any(String), }); expect(workspaces.get("w1")).toEqual({ @@ -777,6 +827,8 @@ describe("WorkspaceReconciliationService", () => { const result = await service.runOnce(); expect(result.changesApplied.map((change) => change.kind).sort()).toEqual([ + "project_updated", + "project_updated", "workspace_updated", "workspace_updated", ]); @@ -785,6 +837,7 @@ describe("WorkspaceReconciliationService", () => { rootPath: repoDir, displayName: "blank-dot-page/editor", customName: null, + projectKey: "remote:github.com/blank-dot-page/editor", archivedAt: null, }); expect(projects.get(repoDir)).toMatchObject({ @@ -792,6 +845,7 @@ describe("WorkspaceReconciliationService", () => { rootPath: repoDir, displayName: "editor", customName: "Editor", + projectKey: "remote:github.com/blank-dot-page/editor", archivedAt: null, }); expect(workspaces.get("focused-bat")).toMatchObject({ @@ -804,7 +858,7 @@ describe("WorkspaceReconciliationService", () => { }); }); - test("keeps project display name stable when git remote changes", async () => { + test("backfills a missing project key while keeping the project display name stable", async () => { const dir = createTempGitRepo("reconcile-remote-"); tempDirs.push(dir); @@ -856,8 +910,103 @@ describe("WorkspaceReconciliationService", () => { const result = await service.runOnce(); - expect(result.changesApplied.find((c) => c.kind === "project_updated")).toBeUndefined(); + expect(result.changesApplied.find((c) => c.kind === "project_updated")).toMatchObject({ + fields: { projectKey: "remote:github.com/new-owner/new-repo" }, + }); expect(projects.get("p1")!.displayName).toBe("old-owner/old-repo"); + expect(projects.get("p1")!.projectKey).toBe("remote:github.com/new-owner/new-repo"); + }); + + test("refreshes an empty project's persisted key when its Git remote changes", async () => { + const dir = createTempGitRepo("reconcile-empty-remote-change-"); + tempDirs.push(dir); + const { projects, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + projectKey: "remote:github.com/old-owner/old-repo", + rootPath: dir, + kind: "git", + displayName: "old-owner/old-repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: createWorkspaceGitServiceStub({ + [dir]: { + projectKind: "git", + projectDisplayName: "new-owner/new-repo", + workspaceDisplayName: "main", + gitRemote: "git@github.com:new-owner/new-repo.git", + }, + }), + }); + + const result = await service.runOnce(); + + expect(result.changesApplied).toEqual([ + expect.objectContaining({ + kind: "project_updated", + fields: { projectKey: "remote:github.com/new-owner/new-repo" }, + }), + ]); + expect(projects.get("p1")?.projectKey).toBe("remote:github.com/new-owner/new-repo"); + }); + + test("refreshes a persisted project key when a Git remote disappears", async () => { + const dir = createTempGitRepo("reconcile-removed-remote-"); + tempDirs.push(dir); + const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries(); + + projects.set( + "p1", + createPersistedProjectRecord({ + projectId: "p1", + projectKey: "remote:github.com/acme/old-repo", + rootPath: dir, + kind: "git", + displayName: "acme/old-repo", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + workspaces.set( + "w1", + createPersistedWorkspaceRecord({ + workspaceId: "w1", + projectId: "p1", + cwd: dir, + kind: "local_checkout", + displayName: "main", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + + const service = new WorkspaceReconciliationService({ + projectRegistry, + workspaceRegistry, + logger: createTestLogger(), + workspaceGitService: createWorkspaceGitServiceStub({ + [dir]: { + projectKind: "git", + projectDisplayName: "old-repo", + workspaceDisplayName: "main", + gitRemote: null, + }, + }), + }); + + await service.runOnce(); + + expect(projects.get("p1")?.projectKey).toBe(canonicalLocalProjectKey(dir)); }); test("keeps custom and default names stable when the remote changes", async () => { @@ -1037,7 +1186,7 @@ describe("WorkspaceReconciliationService", () => { kind: "project_updated", projectId: "p1", directory: projectRoot, - fields: { kind: "git" }, + fields: { kind: "git", projectKey: canonicalLocalProjectKey(projectRoot) }, }, { kind: "workspace_updated", @@ -1321,6 +1470,12 @@ describe("WorkspaceReconciliationService", () => { const result = await service.reconcileGitMetadata(); expect(result.changesApplied).toEqual([ + { + kind: "project_updated", + projectId: "p1", + directory: rootPath, + fields: { projectKey: canonicalLocalProjectKey(rootPath) }, + }, { kind: "workspace_updated", workspaceId: "w1", diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index fa18103587..74cb438de4 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -15,6 +15,7 @@ import { type MutableWorkspacePlacement, } from "./workspace-registry-model.js"; import { workspaceIdsForProjects } from "./workspace-directory.js"; +import { deriveProjectKey } from "./project-key.js"; const DEFAULT_RESCAN_INTERVAL_MS = 5 * 60_000; const DEFAULT_DEBOUNCE_MS = 100; @@ -66,7 +67,7 @@ export type ReconciliationChange = kind: "project_updated"; projectId: string; directory: string; - fields: Partial>; + fields: Partial>; } | { kind: "workspace_updated"; @@ -81,6 +82,7 @@ export interface ReconciliationResult { } export interface WorkspaceReconciliationServiceOptions { + serverId?: string; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; logger: pino.Logger; @@ -111,6 +113,7 @@ interface CachedCheckoutRead { type DirectoryState = "directory" | "missing" | "unreadable"; export class WorkspaceReconciliationService { + private readonly serverId: string | undefined; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly logger: pino.Logger; @@ -133,6 +136,7 @@ export class WorkspaceReconciliationService { private reconcileQueuedMode: "metadata" | "full" | null = null; constructor(options: WorkspaceReconciliationServiceOptions) { + this.serverId = options.serverId; this.projectRegistry = options.projectRegistry; this.workspaceRegistry = options.workspaceRegistry; this.logger = options.logger.child({ module: "workspace-reconciliation" }); @@ -280,6 +284,11 @@ export class WorkspaceReconciliationService { return result; } + /** Runs the boot-time convergence path and publishes every affected workspace. */ + async reconcileNow(): Promise { + await this.reconcileObservedGitMetadata("full"); + } + private async reconcileGitMetadataForProjects( projectsToReconcile: PersistedProjectRecord[], workspacesByProject: Map, @@ -334,12 +343,22 @@ export class WorkspaceReconciliationService { checkout: await readCheckout(workspace.cwd), })), ); - const projectUpdates: Partial> = {}; + const projectUpdates: Partial> = {}; const mappedKind = deriveProjectKind(currentGit); + const projectKey = deriveProjectKey({ + rootPath: project.rootPath, + remoteUrl: currentGit.remoteUrl, + worktreeRoot: currentGit.worktreeRoot, + mainRepoRoot: currentGit.mainRepoRoot, + serverId: this.serverId, + }); if (project.kind !== mappedKind) { projectUpdates.kind = mappedKind; } + if (project.projectKey !== projectKey) { + projectUpdates.projectKey = projectKey; + } if (Object.keys(projectUpdates).length > 0) { const timestamp = new Date().toISOString(); diff --git a/packages/server/src/server/workspace-registry-bootstrap-legacy.ts b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts index 2a712e6a5d..c0ee84d270 100644 --- a/packages/server/src/server/workspace-registry-bootstrap-legacy.ts +++ b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts @@ -3,6 +3,8 @@ import { resolve } from "node:path"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; +import { createRealpathAwarePathMatcher, getRealpathAwareRelativePath } from "../utils/path.js"; +import { deriveProjectKey, deriveProjectGroupingDisplayName } from "./project-key.js"; import { deriveProjectKind, deriveWorkspaceDisplayName, @@ -19,6 +21,7 @@ interface DirectoryProjectMembership { workspaceDirectoryKey: string; workspaceKind: PersistedWorkspaceKind; workspaceDisplayName: string; + projectId: string; projectKey: string; projectName: string; projectRootPath: string; @@ -28,14 +31,18 @@ interface DirectoryProjectMembership { export function classifyDirectoryForProjectMembership(input: { cwd: string; checkout: ProjectCheckoutLitePayload; + serverId?: string; }): DirectoryProjectMembership { const cwd = resolve(input.cwd); const checkout: ProjectCheckoutLitePayload = { ...input.checkout, cwd }; - const projectKey = deriveProjectGroupingKey({ - cwd: checkout.worktreeRoot ?? cwd, + const projectKey = deriveProjectKey({ + rootPath: cwd, remoteUrl: checkout.remoteUrl, + worktreeRoot: checkout.worktreeRoot, mainRepoRoot: checkout.mainRepoRoot, + serverId: input.serverId, }); + const projectRootPath = deriveProjectRootPath({ cwd, checkout }); return { cwd, @@ -43,78 +50,40 @@ export function classifyDirectoryForProjectMembership(input: { workspaceDirectoryKey: deriveWorkspaceDirectoryKey(cwd, checkout), workspaceKind: deriveWorkspaceKind(checkout), workspaceDisplayName: deriveWorkspaceDisplayName({ cwd, checkout }), + // Legacy registry bootstrap historically used the grouping identity as the + // host-local project ID. Preserve that ID shape while keeping the new, + // canonical cross-host identity in projectKey. + projectId: projectKey.startsWith("remote:") ? projectKey : projectRootPath, projectKey, - projectName: deriveProjectGroupingName(projectKey), - projectRootPath: deriveProjectRootPath({ cwd, checkout }), + projectName: deriveProjectGroupingDisplayName({ + rootPath: cwd, + remoteUrl: checkout.remoteUrl, + worktreeRoot: checkout.worktreeRoot, + }), + projectRootPath, projectKind: deriveProjectKind(checkout), }; } function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLitePayload): string { const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null; - return worktreeRoot ?? resolve(cwd); -} - -function deriveRemoteProjectKey(remoteUrl: string | null): string | null { - if (!remoteUrl) return null; - - const trimmed = remoteUrl.trim(); - if (!trimmed) return null; - - let host: string | null = null; - let remotePath: string | null = null; - const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/); - if (scpLike) { - host = scpLike[1] ?? null; - remotePath = scpLike[2] ?? null; - } else if (trimmed.includes("://")) { - try { - const parsed = new URL(trimmed); - host = parsed.hostname || null; - remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null; - } catch { - return null; - } - } - - if (!host || !remotePath) return null; - - let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, ""); - if (cleanedPath.endsWith(".git")) cleanedPath = cleanedPath.slice(0, -4); - if (!cleanedPath.includes("/")) return null; - - return `remote:${host.toLowerCase()}/${cleanedPath}`; -} - -function deriveProjectGroupingKey(options: { - cwd: string; - remoteUrl: string | null; - mainRepoRoot: string | null; -}): string { - const remoteKey = deriveRemoteProjectKey(options.remoteUrl); - if (remoteKey) return remoteKey; - - const mainRepoRoot = options.mainRepoRoot?.trim(); - return mainRepoRoot || options.cwd; -} - -function deriveProjectGroupingName(projectKey: string): string { - if (projectKey.startsWith("remote:")) { - const pathSegments = projectKey.slice("remote:".length).split("/").filter(Boolean).slice(1); - if (pathSegments.length >= 2) return pathSegments.slice(-2).join("/"); - if (pathSegments.length === 1) return pathSegments[0]; - return projectKey; - } - - const segments = projectKey.split(/[\\/]/).filter(Boolean); - return segments[segments.length - 1] || projectKey; + const selectedRoot = resolve(cwd); + return worktreeRoot && createRealpathAwarePathMatcher(worktreeRoot)(selectedRoot) + ? worktreeRoot + : selectedRoot; } function deriveProjectRootPath(input: { cwd: string; checkout: ProjectCheckoutLitePayload; }): string { - return input.checkout.isGit && input.checkout.mainRepoRoot - ? input.checkout.mainRepoRoot - : input.cwd; + if (!input.checkout.isGit || !input.checkout.mainRepoRoot) return input.cwd; + const worktreeRoot = input.checkout.worktreeRoot + ? parseGitRevParsePath(input.checkout.worktreeRoot) + : null; + if (!worktreeRoot) return input.checkout.mainRepoRoot; + const selectedPath = getRealpathAwareRelativePath(worktreeRoot, input.cwd); + return selectedPath + ? resolve(input.checkout.mainRepoRoot, selectedPath) + : input.checkout.mainRepoRoot; } diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index 56ccf4d0bb..a2a1ca5677 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -10,6 +10,7 @@ import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-servic import type { WorkspaceGitService } from "./workspace-git-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; +import { deriveProjectKey } from "./project-key.js"; let NON_GIT_PROJECT: string; let ARCHIVED_PROJECT: string; @@ -242,6 +243,14 @@ describe("bootstrapWorkspaceRegistries", () => { const projects = await projectRegistry.list(); expect(projects).toHaveLength(1); expect(projects[0]?.projectId).toBe(NON_GIT_PROJECT); + expect(projects[0]?.projectKey).toBe( + deriveProjectKey({ + rootPath: NON_GIT_PROJECT, + remoteUrl: null, + worktreeRoot: null, + mainRepoRoot: null, + }), + ); expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z"); expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z"); }); @@ -351,6 +360,7 @@ describe("bootstrapWorkspaceRegistries", () => { expect(projects).toHaveLength(1); expect(projects[0]).toMatchObject({ projectId: "remote:github.com/acme/legacy-project", + projectKey: "remote:github.com/acme/legacy-project", rootPath: GIT_PROJECT, kind: "git", displayName: "acme/legacy-project", diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index 8a75d5323e..64bf2d4eb1 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -45,6 +45,7 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string { } export async function bootstrapWorkspaceRegistries(options: { + serverId?: string; paseoHome: string; agentStorage: AgentStorage; projectRegistry: ProjectRegistry; @@ -96,6 +97,7 @@ export async function bootstrapWorkspaceRegistries(options: { const membership = classifyDirectoryForProjectMembership({ cwd: normalizedCwd, checkout, + serverId: options.serverId, }); return { record, membership, directoryKey: membership.workspaceDirectoryKey }; }), @@ -156,7 +158,7 @@ export async function bootstrapWorkspaceRegistries(options: { options.workspaceRegistry.upsert( createPersistedWorkspaceRecord({ workspaceId, - projectId: membership.projectKey, + projectId: membership.projectId, cwd: workspaceCwd, kind: membership.workspaceKind, displayName: membership.workspaceDisplayName, @@ -166,10 +168,11 @@ export async function bootstrapWorkspaceRegistries(options: { ), options.projectRegistry.upsert( createPersistedProjectRecord({ - projectId: membership.projectKey, + projectId: membership.projectId, rootPath: membership.projectRootPath, kind: membership.projectKind, displayName: membership.projectName, + projectKey: membership.projectKey, createdAt: projectRange.createdAt ?? createdAt, updatedAt: projectRange.updatedAt ?? updatedAt, }), diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 14359f80c1..fe28048120 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -16,6 +16,12 @@ const PersistedProjectRecordSchema = z.object({ rootPath: z.string(), kind: z.enum(["git", "non_git"]), displayName: z.string(), + // COMPAT(projectKey): added in v0.2.4 on 2026-07-28; remove optional after 2027-01-28. + projectKey: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), // User-set override layered over the derived displayName. Reconciliation // never touches this. Null means "use the derived name". Added for #987. customName: z @@ -104,6 +110,7 @@ export interface ProjectRegistry { rootPath: string; kind: PersistedProjectKind; displayName: string; + projectKey?: string; timestamp: string; }): Promise; upsert(record: PersistedProjectRecord): Promise; @@ -308,6 +315,7 @@ export class FileBackedProjectRegistry rootPath: string; kind: PersistedProjectKind; displayName: string; + projectKey?: string; timestamp: string; }): Promise { const previous = this.allocationQueue; @@ -325,8 +333,14 @@ export class FileBackedProjectRegistry left.projectId.localeCompare(right.projectId), )[0]; if (active) { - if (active.kind === input.kind) return active; - const refreshed = { ...active, kind: input.kind, updatedAt: input.timestamp }; + if (active.kind === input.kind && active.projectKey === (input.projectKey ?? null)) + return active; + const refreshed = { + ...active, + kind: input.kind, + projectKey: input.projectKey ?? null, + updatedAt: input.timestamp, + }; await this.upsert(refreshed); return refreshed; } @@ -339,6 +353,7 @@ export class FileBackedProjectRegistry rootPath: input.rootPath, kind: input.kind, displayName: input.displayName, + projectKey: input.projectKey ?? null, createdAt: input.timestamp, updatedAt: input.timestamp, }); @@ -459,6 +474,7 @@ export function createPersistedProjectRecord(input: { kind: PersistedProjectKind; displayName: string; customName?: string | null; + projectKey?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; @@ -466,6 +482,7 @@ export function createPersistedProjectRecord(input: { return PersistedProjectRecordSchema.parse({ ...input, customName: input.customName ?? null, + projectKey: input.projectKey ?? null, archivedAt: input.archivedAt ?? null, }); } diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index 150bb184d8..3190e7a1d1 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -62,6 +62,11 @@ export function isPathInsideRoot(root: string, candidate: string): boolean { return getRelativePathInsideRoot(root, candidate) !== null; } +export function normalizePathForIdentity(value: string): string { + const canonicalPath = resolveRealpathVariants(value)[0] ?? value; + return normalizePathForComparison(canonicalPath, looksLikeDefiniteWindowsPath(canonicalPath)); +} + /** * Returns the candidate's relative suffix when it is inside root. * From c6df5ffd4d5072d67502f7ad2873045189ce10ab Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Jul 2026 00:37:52 +0200 Subject: [PATCH 08/46] Revert "Carry local files and shared state into new worktrees (#2419)" This reverts commit cbbf6c1684fb0415b7949e684d152f5f7453e769. --- SECURITY.md | 6 - docs/development.md | 1 - .../src/server/paseo-worktree-service.ts | 3 - packages/server/src/server/session.ts | 1 - .../workspace-recovery-service.test.ts | 14 - .../workspace-recovery-service.ts | 12 - packages/server/src/server/worktree-core.ts | 20 +- .../server/src/server/worktree-session.ts | 11 - .../server/src/utils/worktree-include.test.ts | 635 -------- packages/server/src/utils/worktree-include.ts | 1359 ----------------- .../server/src/utils/worktree.posix.test.ts | 298 ---- packages/server/src/utils/worktree.ts | 470 ++---- public-docs/worktrees.md | 40 - 13 files changed, 145 insertions(+), 2725 deletions(-) delete mode 100644 packages/server/src/utils/worktree-include.test.ts delete mode 100644 packages/server/src/utils/worktree-include.ts diff --git a/SECURITY.md b/SECURITY.md index 4acb376b48..dacb14c41a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,12 +50,6 @@ The daemon also supports an optional shared-secret password (set via `auth.passw Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary. -An explicit `symlink ` entry in a repository's .worktreeinclude intentionally gives a -Paseo-created worktree live access to that source-checkout file or directory. It is useful for -local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle -scripts can modify the source through the link. Paseo validates entries and refuses traversal or -destination-link escapes, but the linked source is a deliberate shared-data boundary. - If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case. In Docker, the official image runs the daemon and agents as the non-root diff --git a/docs/development.md b/docs/development.md index 31b35d0120..cd96239bac 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,7 +29,6 @@ Root checkout dev is intentionally split across terminals: - **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app. - **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint. - **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied. -- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy a snapshot into the new worktree; `symlink ` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs. - **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so. Override knobs: diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index fa2e61cf02..f09a813380 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -28,7 +28,6 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent. import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import type { FirstAgentContext } from "@getpaseo/protocol/messages"; -import type { WorktreeIncludeSummary } from "../utils/worktree-include.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { projectId?: string; @@ -37,7 +36,6 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { export interface CreatePaseoWorktreeResult { worktree: WorktreeConfig; - worktreeIncludeSummary?: WorktreeIncludeSummary; intent: WorktreeCreationIntent; workspace: PersistedWorkspaceRecord; repoRoot: string; @@ -100,7 +98,6 @@ export async function createPaseoWorktree( return { worktree: createdWorktree.worktree, - worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary, intent: createdWorktree.intent, workspace, repoRoot: createdWorktree.repoRoot, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d010a47a1d..a87aa42e6c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -746,7 +746,6 @@ export class Session { logger: this.sessionLogger, }); this.workspaceRecovery = createWorkspaceRecoveryService({ - logger: this.sessionLogger, paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 2203e30728..91631a5631 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -76,7 +76,6 @@ function createHarness(input?: { const directories = new Set(input?.directories ?? ["/repo"]); const unarchived: string[] = []; const service = createWorkspaceRecoveryService({ - logger: { warn: () => undefined } as never, paseoHome: input?.paseoHome ?? "/paseo-home", worktreesRoot: input?.worktreesRoot ?? "/worktrees", getWorkspace: async (workspaceId) => @@ -136,7 +135,6 @@ describe("workspace recovery", () => { const sourceSubdirectory = join(repoDir, "packages", "app"); mkdirSync(sourceSubdirectory, { recursive: true }); writeFileSync(join(sourceSubdirectory, "README.md"), "app\n"); - writeFileSync(join(repoDir, ".worktreeinclude"), "missing.local\n"); execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); @@ -172,9 +170,7 @@ describe("workspace recovery", () => { mainRepoRoot: repoDir, }); const unarchived: string[] = []; - const warnings: unknown[][] = []; const service = createWorkspaceRecoveryService({ - logger: { warn: (...args: unknown[]) => warnings.push(args) } as never, paseoHome, worktreesRoot, getWorkspace: async (workspaceId) => @@ -193,15 +189,6 @@ describe("workspace recovery", () => { expect(existsSync(worktreeRoot)).toBe(true); expect(existsSync(workspaceCwd)).toBe(true); expect(unarchived).toEqual([workspace.workspaceId]); - expect(warnings).toEqual([ - [ - expect.objectContaining({ - materialized: 0, - skipped: [expect.objectContaining({ raw: "missing.local", reason: "missing" })], - }), - "Worktree include completed with skipped entries during workspace recovery", - ], - ]); }); test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => { @@ -233,7 +220,6 @@ describe("workspace recovery", () => { }); const unarchived: string[] = []; const service = createWorkspaceRecoveryService({ - logger: { warn: () => undefined } as never, paseoHome, worktreesRoot, getWorkspace: async (workspaceId) => diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 539dc218fc..1d98a9128c 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -1,5 +1,4 @@ import { basename } from "node:path"; -import type { Logger } from "pino"; import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; import { runGitCommand } from "../../../utils/run-git-command.js"; @@ -60,7 +59,6 @@ type RecoveryPlan = type UnavailableRecoveryState = Extract; export function createWorkspaceRecoveryService(deps: { - logger: Logger; paseoHome: string; worktreesRoot?: string; getWorkspace: (workspaceId: string) => Promise; @@ -197,16 +195,6 @@ export function createWorkspaceRecoveryService(deps: { worktreesRoot: deps.worktreesRoot, }); recreatedWorktreePath = result.worktreePath; - if (result.worktreeIncludeSummary.skipped.length > 0) { - deps.logger.warn( - { - materialized: result.worktreeIncludeSummary.materialized, - skipped: result.worktreeIncludeSummary.skipped, - worktreePath: result.worktreePath, - }, - "Worktree include completed with skipped entries during workspace recovery", - ); - } } catch (error) { throw toWorktreeRequestError(error); } diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index c83fdf7b6e..90d0788905 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -8,7 +8,6 @@ import { validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; -import type { WorktreeIncludeSummary } from "../utils/worktree-include.js"; import { resolveWorktreeCreationIntent, type ResolveWorktreeCreationIntentInput, @@ -43,7 +42,6 @@ export interface CreateWorktreeCoreDeps { export interface CreateWorktreeCoreResult { worktree: WorktreeConfig; - worktreeIncludeSummary?: WorktreeIncludeSummary; intent: WorktreeCreationIntent; repoRoot: string; created: boolean; @@ -122,17 +120,15 @@ export async function createWorktreeCore( return { worktree: existingWorktree, intent, repoRoot, created: false }; } - const { worktreeIncludeSummary, ...worktree } = await createWorktree({ - cwd: repoRoot, - worktreeSlug: normalizedSlug, - source: intent, - runSetup: input.runSetup ?? true, - paseoHome: input.paseoHome, - worktreesRoot: input.worktreesRoot, - }); return { - worktree, - worktreeIncludeSummary, + worktree: await createWorktree({ + cwd: repoRoot, + worktreeSlug: normalizedSlug, + source: intent, + runSetup: input.runSetup ?? true, + paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, + }), intent, repoRoot, created: true, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 3b558affc0..5573a945f8 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -605,17 +605,6 @@ export async function createPaseoWorktreeWorkflow( const workspace = createdWorktree.workspace; const setupContinuation = options?.setupContinuation ?? { kind: "workspace" }; - if (createdWorktree.created && createdWorktree.worktreeIncludeSummary?.skipped.length) { - dependencies.sessionLogger.warn( - { - materialized: createdWorktree.worktreeIncludeSummary.materialized, - skipped: createdWorktree.worktreeIncludeSummary.skipped, - worktreePath: createdWorktree.worktree.worktreePath, - }, - "Worktree include completed with skipped entries", - ); - } - setTimeout(() => { if (input.firstAgentContext) { dependencies.autoNameWorkspaceBranchForFirstAgent({ diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts deleted file mode 100644 index b846b9b7cc..0000000000 --- a/packages/server/src/utils/worktree-include.test.ts +++ /dev/null @@ -1,635 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - existsSync, - chmodSync, - lstatSync, - linkSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - readlinkSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from "fs"; -import { tmpdir } from "os"; -import { dirname, join, relative } from "path"; -import { isPlatform } from "../test-utils/platform.js"; -import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js"; - -describe("worktree include planning", () => { - let tempDir: string; - let sourceRoot: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "worktree-include-test-")); - sourceRoot = join(tempDir, "source"); - mkdirSync(sourceRoot); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - it("copies bare entries and accepts explicit copy and symlink modes", async () => { - writeFileSync( - join(sourceRoot, ".worktreeinclude"), - [".env.local", ".cache/**", "symlink shared-state", "copy packages/*/.runtime.env", ""].join( - "\n", - ), - ); - writeFileSync(join(sourceRoot, ".env.local"), "source\n"); - mkdirSync(join(sourceRoot, ".cache"), { recursive: true }); - writeFileSync(join(sourceRoot, ".cache", "state.txt"), "cache\n"); - mkdirSync(join(sourceRoot, "shared-state"), { recursive: true }); - writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "shared\n"); - mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true }); - writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toHaveLength(4); - expect(plan.materializations).toEqual( - expect.arrayContaining([ - expect.objectContaining({ mode: "copy", relativePath: ".env.local", sourceKind: "file" }), - expect.objectContaining({ mode: "copy", relativePath: ".cache", sourceKind: "directory" }), - expect.objectContaining({ - mode: "symlink", - relativePath: "shared-state", - sourceKind: "directory", - }), - expect.objectContaining({ - mode: "copy", - relativePath: "packages/api/.runtime.env", - sourceKind: "file", - }), - ]), - ); - }); - - it("skips invalid and conflicting entries while retaining safe entries", async () => { - writeFileSync(join(sourceRoot, "shared"), "source\n"); - writeFileSync(join(sourceRoot, ".env"), "source\n"); - writeFileSync( - join(sourceRoot, ".worktreeinclude"), - ["../outside", "symlink", "shared", "symlink shared", ".env", ""].join("\n"), - ); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([ - expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }), - ]); - expect(plan.skipped).toEqual( - expect.arrayContaining([ - expect.objectContaining({ lineNumber: 1, raw: "../outside", reason: "invalid" }), - expect.objectContaining({ lineNumber: 2, raw: "symlink", reason: "invalid" }), - expect.objectContaining({ lineNumber: 3, raw: "shared", reason: "conflict" }), - expect.objectContaining({ lineNumber: 4, raw: "symlink shared", reason: "conflict" }), - ]), - ); - }); - - it("skips missing entries while retaining existing literal and glob matches", async () => { - writeFileSync( - join(sourceRoot, ".worktreeinclude"), - [".env", ".env.local", "config/*.env", "missing/**", ""].join("\n"), - ); - writeFileSync(join(sourceRoot, ".env"), "source\n"); - mkdirSync(join(sourceRoot, "config"), { recursive: true }); - writeFileSync(join(sourceRoot, "config", "runtime.env"), "runtime\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([ - expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }), - expect.objectContaining({ - mode: "copy", - relativePath: "config/runtime.env", - sourceKind: "file", - }), - ]); - expect(plan.skipped).toEqual( - expect.arrayContaining([ - expect.objectContaining({ raw: ".env.local", reason: "missing" }), - expect.objectContaining({ raw: "missing/**", reason: "missing" }), - ]), - ); - }); - - it("requires the optimized trailing recursive form to resolve to a directory", async () => { - writeFileSync(join(sourceRoot, "cache"), "not-a-directory\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([expect.objectContaining({ raw: "cache/**", reason: "unsafe" })]); - }); - - it("skips directory copies that overlap protected worktree paths", async () => { - const protectedWorktreeRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"); - mkdirSync(protectedWorktreeRoot, { recursive: true }); - writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n"); - - const plan = await readWorktreeIncludePlan({ - sourceRoot, - excludedSourceRoots: [protectedWorktreeRoot], - }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]); - }); - - it("protects every managed project beneath checkout-local worktree storage", async () => { - const managedWorktreesRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees"); - const siblingWorktree = join(managedWorktreesRoot, "other-project", "sibling"); - mkdirSync(siblingWorktree, { recursive: true }); - writeFileSync(join(siblingWorktree, ".env"), "secret\n"); - writeFileSync( - join(sourceRoot, ".worktreeinclude"), - ".dev/paseo-home/worktrees/other-project/sibling/.env\n", - ); - - const plan = await readWorktreeIncludePlan({ - sourceRoot, - excludedSourceRoots: [managedWorktreesRoot], - }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([expect.objectContaining({ reason: "unsafe" })]); - }); - - it("allows includes from a source worktree inside the protected worktree root", async () => { - const managedWorktreesRoot = join(tempDir, "paseo-home", "worktrees", "project"); - sourceRoot = join(managedWorktreesRoot, "source-worktree"); - mkdirSync(sourceRoot, { recursive: true }); - writeFileSync(join(sourceRoot, ".worktreeinclude"), ".env\n"); - writeFileSync(join(sourceRoot, ".env"), "source\n"); - - const plan = await readWorktreeIncludePlan({ - sourceRoot, - excludedSourceRoots: [managedWorktreesRoot], - }); - - expect(plan.materializations).toEqual([ - expect.objectContaining({ relativePath: ".env", sourceKind: "file" }), - ]); - expect(plan.skipped).toEqual([]); - }); - - it.skipIf(isPlatform("win32"))( - "skips protected paths reached through a symlink alias", - async () => { - const sourceAlias = join(tempDir, "source-alias"); - symlinkSync(sourceRoot, sourceAlias, "dir"); - mkdirSync(join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"), { - recursive: true, - }); - writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n"); - - const plan = await readWorktreeIncludePlan({ - sourceRoot, - excludedSourceRoots: [join(sourceAlias, ".dev", "paseo-home", "worktrees", "project")], - }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]); - }, - ); - - it.skipIf(isPlatform("win32"))( - "skips external source links while retaining safe entries", - async () => { - const outsidePath = join(tempDir, "outside.txt"); - writeFileSync(outsidePath, "outside\n"); - writeFileSync(join(sourceRoot, "safe.txt"), "safe\n"); - symlinkSync(outsidePath, join(sourceRoot, "linked.txt")); - writeFileSync( - join(sourceRoot, ".worktreeinclude"), - ["safe.txt", "linked.txt", ""].join("\n"), - ); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([ - expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }), - ]); - expect(plan.skipped).toEqual([ - expect.objectContaining({ raw: "linked.txt", reason: "unsafe" }), - ]); - }, - ); - - it.skipIf(isPlatform("win32"))("matches globs beneath a safe symlinked directory", async () => { - mkdirSync(join(sourceRoot, "actual-config")); - writeFileSync(join(sourceRoot, "actual-config", "runtime.env"), "runtime\n"); - symlinkSync(join(sourceRoot, "actual-config"), join(sourceRoot, "config"), "dir"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/*.env\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([ - expect.objectContaining({ relativePath: "config/runtime.env", sourceKind: "file" }), - ]); - expect(plan.skipped).toEqual([]); - }); - - it.skipIf(isPlatform("win32"))( - "does not scan unrelated directories for bounded globs", - async () => { - writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n"); - mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true }); - writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n"); - const unreadableDirectory = join(sourceRoot, "unrelated"); - mkdirSync(unreadableDirectory); - chmodSync(unreadableDirectory, 0o000); - - try { - const plan = await readWorktreeIncludePlan({ sourceRoot }); - expect(plan.materializations).toEqual([ - expect.objectContaining({ relativePath: "packages/api/.runtime.env" }), - ]); - } finally { - chmodSync(unreadableDirectory, 0o700); - } - }, - ); - - it.skipIf(isPlatform("win32") || process.getuid?.() === 0)( - "skips inaccessible entries while retaining safe paths", - async () => { - const inaccessibleDirectory = join(sourceRoot, "private"); - mkdirSync(inaccessibleDirectory); - writeFileSync(join(inaccessibleDirectory, "secret.txt"), "secret\n"); - writeFileSync(join(sourceRoot, "safe.txt"), "safe\n"); - writeFileSync( - join(sourceRoot, ".worktreeinclude"), - ["private/**", "private/*", "safe.txt", ""].join("\n"), - ); - chmodSync(inaccessibleDirectory, 0o000); - - try { - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([ - expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }), - ]); - expect(plan.skipped).toEqual( - expect.arrayContaining([ - expect.objectContaining({ raw: "private/**", reason: "materialization" }), - expect.objectContaining({ raw: "private/*", reason: "materialization" }), - ]), - ); - } finally { - chmodSync(inaccessibleDirectory, 0o700); - } - }, - ); - - it.skipIf(isPlatform("win32") || process.getuid?.() === 0)( - "retains valid glob matches when a viable sibling is unreadable", - async () => { - mkdirSync(join(sourceRoot, "packages", "good"), { recursive: true }); - mkdirSync(join(sourceRoot, "packages", "private"), { recursive: true }); - writeFileSync(join(sourceRoot, "packages", "good", ".runtime.env"), "good\n"); - writeFileSync(join(sourceRoot, "packages", "private", ".runtime.env"), "private\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n"); - chmodSync(join(sourceRoot, "packages", "private"), 0o000); - - try { - const plan = await readWorktreeIncludePlan({ sourceRoot }); - expect(plan.materializations).toEqual([ - expect.objectContaining({ relativePath: "packages/good/.runtime.env" }), - ]); - expect(plan.skipped).toEqual([ - expect.objectContaining({ raw: "packages/*/.runtime.env", reason: "materialization" }), - ]); - } finally { - chmodSync(join(sourceRoot, "packages", "private"), 0o700); - } - }, - ); -}); - -describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { - let tempDir: string; - let sourceRoot: string; - let worktreeRoot: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "worktree-include-materialize-test-")); - sourceRoot = join(tempDir, "source"); - worktreeRoot = join(tempDir, "worktree"); - mkdirSync(sourceRoot); - mkdirSync(worktreeRoot); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - it("copies snapshots, links shared paths, and is idempotent", async () => { - writeFileSync( - join(sourceRoot, ".worktreeinclude"), - ["copy.txt", "copy-dir/**", "symlink linked.txt", "symlink linked-dir"].join("\n"), - ); - writeFileSync(join(sourceRoot, "copy.txt"), "copy-v1\n"); - mkdirSync(join(sourceRoot, "copy-dir"), { recursive: true }); - writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v1\n"); - writeFileSync(join(sourceRoot, "linked.txt"), "linked-v1\n"); - mkdirSync(join(sourceRoot, "linked-dir"), { recursive: true }); - writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v1\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - expect(lstatSync(join(worktreeRoot, "copy.txt")).isSymbolicLink()).toBe(false); - expect(lstatSync(join(worktreeRoot, "copy-dir")).isSymbolicLink()).toBe(false); - expect(lstatSync(join(worktreeRoot, "linked.txt")).isSymbolicLink()).toBe(true); - expect(lstatSync(join(worktreeRoot, "linked-dir")).isSymbolicLink()).toBe(true); - - writeFileSync(join(sourceRoot, "copy.txt"), "copy-v2\n"); - writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v2\n"); - writeFileSync(join(sourceRoot, "linked.txt"), "linked-v2\n"); - writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v2\n"); - - expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v1\n"); - expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v1\n"); - expect(readFileSync(join(worktreeRoot, "linked.txt"), "utf8")).toBe("linked-v2\n"); - expect(readFileSync(join(worktreeRoot, "linked-dir", "state.txt"), "utf8")).toBe( - "linked-dir-v2\n", - ); - - await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v2\n"); - expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v2\n"); - }); - - it("replaces an existing directory snapshot without retaining destination-only files", async () => { - mkdirSync(join(sourceRoot, "cache")); - writeFileSync(join(sourceRoot, "cache", "current.txt"), "current\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n"); - mkdirSync(join(worktreeRoot, "cache")); - writeFileSync(join(worktreeRoot, "cache", "stale.txt"), "stale\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - expect(result).toMatchObject({ materialized: 1, skipped: [] }); - expect(readFileSync(join(worktreeRoot, "cache", "current.txt"), "utf8")).toBe("current\n"); - expect(existsSync(join(worktreeRoot, "cache", "stale.txt"))).toBe(false); - }); - - it("retains an explicit descendant when an overlapping directory copy is skipped", async () => { - mkdirSync(join(sourceRoot, "config", "nested"), { recursive: true }); - writeFileSync(join(sourceRoot, "config", "local.env"), "local\n"); - writeFileSync(join(sourceRoot, "config", "nested", "state.txt"), "state\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/**\nconfig/local.env\n"); - mkdirSync(join(worktreeRoot, "config")); - writeFileSync(join(worktreeRoot, "config", "nested"), "conflict\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - expect(result).toMatchObject({ materialized: 1 }); - expect(result.skipped).toEqual([ - expect.objectContaining({ raw: "config/**", reason: "conflict" }), - ]); - expect(readFileSync(join(worktreeRoot, "config", "local.env"), "utf8")).toBe("local\n"); - }); - - it("skips a destination parent symlink without writing through it", async () => { - mkdirSync(join(sourceRoot, "config"), { recursive: true }); - writeFileSync(join(sourceRoot, "config", "local.json"), "{}\n"); - writeFileSync(join(sourceRoot, "safe.txt"), "safe\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/local.json\nsafe.txt\n"); - - const outsideRoot = join(tempDir, "outside"); - mkdirSync(outsideRoot); - symlinkSync(outsideRoot, join(worktreeRoot, "config")); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - expect(existsSync(join(outsideRoot, "local.json"))).toBe(false); - expect(readFileSync(join(worktreeRoot, "safe.txt"), "utf8")).toBe("safe\n"); - expect(result).toMatchObject({ materialized: 1 }); - expect(result.skipped).toEqual([ - expect.objectContaining({ raw: "config/local.json", reason: "conflict" }), - ]); - expect(readdirSync(worktreeRoot)).not.toContain( - expect.stringMatching(/^\.paseo-worktreeinclude-/), - ); - }); - - it("copies resolved source links and creates direct live links", async () => { - const targetPath = join(sourceRoot, "target.txt"); - const targetDirectoryPath = join(sourceRoot, "target-directory"); - writeFileSync(targetPath, "v1\n"); - mkdirSync(targetDirectoryPath); - writeFileSync(join(targetDirectoryPath, "state.txt"), "v1\n"); - symlinkSync(targetPath, join(sourceRoot, "copy-link.txt")); - symlinkSync(targetPath, join(sourceRoot, "live-link.txt")); - symlinkSync(targetDirectoryPath, join(sourceRoot, "copy-directory-link"), "dir"); - symlinkSync(targetDirectoryPath, join(sourceRoot, "live-directory-link"), "dir"); - writeFileSync( - join(sourceRoot, ".worktreeinclude"), - [ - "copy-link.txt", - "symlink live-link.txt", - "copy-directory-link", - "symlink live-directory-link", - "", - ].join("\n"), - ); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - const copiedPath = join(worktreeRoot, "copy-link.txt"); - const linkedPath = join(worktreeRoot, "live-link.txt"); - const copiedDirectoryPath = join(worktreeRoot, "copy-directory-link"); - const linkedDirectoryPath = join(worktreeRoot, "live-directory-link"); - const canonicalWorktreeRoot = realpathSync(worktreeRoot); - expect(result).toMatchObject({ materialized: 4, skipped: [] }); - expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false); - expect(lstatSync(linkedPath).isSymbolicLink()).toBe(true); - expect(lstatSync(copiedDirectoryPath).isSymbolicLink()).toBe(false); - expect(lstatSync(linkedDirectoryPath).isSymbolicLink()).toBe(true); - expect(readlinkSync(linkedPath)).toBe( - relative(dirname(join(canonicalWorktreeRoot, "live-link.txt")), realpathSync(targetPath)), - ); - expect(readlinkSync(linkedDirectoryPath)).toBe( - relative( - dirname(join(canonicalWorktreeRoot, "live-directory-link")), - realpathSync(targetDirectoryPath), - ), - ); - expect(realpathSync(linkedPath)).toBe(realpathSync(targetPath)); - expect(realpathSync(linkedDirectoryPath)).toBe(realpathSync(targetDirectoryPath)); - - writeFileSync(targetPath, "v2\n"); - writeFileSync(join(targetDirectoryPath, "state.txt"), "v2\n"); - expect(readFileSync(copiedPath, "utf8")).toBe("v1\n"); - expect(readFileSync(linkedPath, "utf8")).toBe("v2\n"); - expect(readFileSync(join(copiedDirectoryPath, "state.txt"), "utf8")).toBe("v1\n"); - expect(readFileSync(join(linkedDirectoryPath, "state.txt"), "utf8")).toBe("v2\n"); - }); - - it("rejects source links that alias Git metadata", async () => { - mkdirSync(join(sourceRoot, ".git")); - writeFileSync(join(sourceRoot, ".git", "config"), "secret\n"); - symlinkSync(join(sourceRoot, ".git"), join(sourceRoot, "metadata"), "dir"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "metadata\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([expect.objectContaining({ raw: "metadata", reason: "unsafe" })]); - }); - - it("treats hard links as ordinary files", async () => { - const targetPath = join(sourceRoot, "target.txt"); - const hardLinkPath = join(sourceRoot, "hard-link.txt"); - writeFileSync(targetPath, "v1\n"); - linkSync(targetPath, hardLinkPath); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "hard-link.txt\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - const copiedPath = join(worktreeRoot, "hard-link.txt"); - expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false); - expect(readFileSync(copiedPath, "utf8")).toBe("v1\n"); - }); - - it("skips a source link retargeted outside the checkout after planning", async () => { - const insidePath = join(sourceRoot, "inside.txt"); - const linkedPath = join(sourceRoot, "linked.txt"); - const outsidePath = join(tempDir, "outside.txt"); - writeFileSync(insidePath, "inside\n"); - writeFileSync(outsidePath, "outside\n"); - symlinkSync(insidePath, linkedPath); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink linked.txt\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - rmSync(linkedPath); - symlinkSync(outsidePath, linkedPath); - - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - expect(existsSync(join(worktreeRoot, "linked.txt"))).toBe(false); - expect(result.skipped).toEqual([ - expect.objectContaining({ raw: "symlink linked.txt", reason: "unsafe" }), - ]); - }); - - it("skips a linked directory that exposes an external nested link", async () => { - const sharedPath = join(sourceRoot, "shared"); - const outsidePath = join(tempDir, "outside.txt"); - mkdirSync(sharedPath); - writeFileSync(outsidePath, "outside\n"); - symlinkSync(outsidePath, join(sharedPath, "outside.txt")); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([ - expect.objectContaining({ raw: "symlink shared", reason: "unsafe" }), - ]); - }); - - it("allows a linked directory with internal nested links", async () => { - const sharedPath = join(sourceRoot, "shared"); - const targetPath = join(sourceRoot, "shared-target"); - mkdirSync(sharedPath); - mkdirSync(targetPath); - writeFileSync(join(targetPath, "state.txt"), "source\n"); - symlinkSync(targetPath, join(sharedPath, "target"), "dir"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - expect(result).toMatchObject({ materialized: 1, skipped: [] }); - expect(readFileSync(join(worktreeRoot, "shared", "target", "state.txt"), "utf8")).toBe( - "source\n", - ); - }); - - it("skips a source removed after planning", async () => { - writeFileSync(join(sourceRoot, ".worktreeinclude"), "runtime.env\n"); - const sourcePath = join(sourceRoot, "runtime.env"); - writeFileSync(sourcePath, "source\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - rmSync(sourcePath); - - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - expect(existsSync(join(worktreeRoot, "runtime.env"))).toBe(false); - expect(result.skipped).toEqual([ - expect.objectContaining({ raw: "runtime.env", reason: "missing" }), - ]); - }); - - it.skipIf(process.getuid?.() === 0)( - "skips ordinary filesystem failures during materialization revalidation", - async () => { - mkdirSync(join(sourceRoot, "private")); - writeFileSync(join(sourceRoot, "private", "state.txt"), "private\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "private/**\n"); - const plan = await readWorktreeIncludePlan({ sourceRoot }); - chmodSync(join(sourceRoot, "private"), 0o000); - - try { - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - expect(result).toMatchObject({ materialized: 0 }); - expect(result.skipped).toEqual([ - expect.objectContaining({ raw: "private/**", reason: "materialization" }), - ]); - } finally { - chmodSync(join(sourceRoot, "private"), 0o700); - } - }, - ); -}); - -describe.skipIf(!isPlatform("win32"))("worktree include Windows directory links", () => { - let tempDir: string; - let sourceRoot: string; - let worktreeRoot: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "worktree-include-windows-test-")); - sourceRoot = join(tempDir, "source"); - worktreeRoot = join(tempDir, "worktree"); - mkdirSync(sourceRoot); - mkdirSync(worktreeRoot); - }); - - afterEach(() => { - rmSync(tempDir, { recursive: true, force: true }); - }); - - it("uses a live directory link and removes it without touching the source", async () => { - mkdirSync(join(sourceRoot, "shared-state")); - writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v1\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared-state\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v2\n"); - expect(readFileSync(join(worktreeRoot, "shared-state", "state.txt"), "utf8")).toBe( - "source-v2\n", - ); - - rmSync(worktreeRoot, { recursive: true, force: true }); - expect(readFileSync(join(sourceRoot, "shared-state", "state.txt"), "utf8")).toBe("source-v2\n"); - }); -}); diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts deleted file mode 100644 index 32c00abbc2..0000000000 --- a/packages/server/src/utils/worktree-include.ts +++ /dev/null @@ -1,1359 +0,0 @@ -import { type Dirent, type Stats } from "fs"; -import { - copyFile, - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - realpath, - rename, - rm, - rmdir, - symlink, -} from "fs/promises"; -import { - basename as pathBasename, - dirname, - isAbsolute, - join, - relative, - resolve, - win32, -} from "path"; -import { areEquivalentPaths, isPathInsideRoot } from "./path.js"; - -const WORKTREE_INCLUDE_FILE_NAME = ".worktreeinclude"; - -export type WorktreeIncludeMode = "copy" | "symlink"; -type WorktreeIncludeSourceKind = "file" | "directory"; -type WorktreeIncludeErrorCode = - | "conflict" - | "invalid_entry" - | "missing_source" - | "source_changed" - | "unsupported_source" - | "windows_symlink_unavailable"; - -export type WorktreeIncludeSkipReason = - | "conflict" - | "invalid" - | "materialization" - | "missing" - | "source_changed" - | "unsafe"; - -interface WorktreeIncludeEntry { - lineNumber: number; - mode: WorktreeIncludeMode; - raw: string; - relativePath: string; -} - -export interface WorktreeIncludeMaterialization { - lineNumber: number; - mode: WorktreeIncludeMode; - raw: string; - relativePath: string; - sourceKind: WorktreeIncludeSourceKind; -} - -export interface WorktreeIncludeSkippedEntry { - lineNumber: number; - message: string; - raw: string; - reason: WorktreeIncludeSkipReason; -} - -export interface WorktreeIncludeSummary { - materialized: number; - skipped: WorktreeIncludeSkippedEntry[]; -} - -export interface WorktreeIncludePlan { - excludedSourceRoots: string[]; - materializations: WorktreeIncludeMaterialization[]; - skipped: WorktreeIncludeSkippedEntry[]; - sourceRoot: string; -} - -export interface ReadWorktreeIncludePlanOptions { - excludedSourceRoots?: string[]; - sourceRoot: string; -} - -export interface MaterializeWorktreeIncludePlanOptions { - plan: WorktreeIncludePlan; - worktreeRoot: string; -} - -interface ResolvedWorktreeIncludeMaterialization { - materialization: WorktreeIncludeMaterialization; - sourcePath: string; -} - -interface StagedWorktreeIncludeMaterialization { - directoryPath: string; - entryPath: string; -} - -interface ParsedWorktreeIncludeEntries { - entries: WorktreeIncludeEntry[]; - skipped: WorktreeIncludeSkippedEntry[]; -} - -interface NormalizedWorktreeIncludeMaterializations { - materializations: WorktreeIncludeMaterialization[]; - skipped: WorktreeIncludeSkippedEntry[]; -} - -interface WorktreeIncludeCandidateCollection { - candidates: string[]; - errorsByPattern: Map; -} - -export class WorktreeIncludeError extends Error { - constructor( - public readonly code: WorktreeIncludeErrorCode, - message: string, - ) { - super(message); - this.name = "WorktreeIncludeError"; - } -} - -class WorktreeIncludeCleanupError extends Error { - constructor( - message: string, - public readonly cleanupError: unknown, - ) { - super(message); - this.name = "WorktreeIncludeCleanupError"; - } -} - -export async function readWorktreeIncludePlan( - options: ReadWorktreeIncludePlanOptions, -): Promise { - const sourceRoot = await realpath(options.sourceRoot); - const { entries, skipped } = await readWorktreeIncludeEntries(sourceRoot); - if (entries.length === 0) { - return { - sourceRoot, - excludedSourceRoots: [], - materializations: [], - skipped, - }; - } - - const excludedSourceRoots = ( - await Promise.all((options.excludedSourceRoots ?? []).map(canonicalizeExistingPathPrefix)) - ).filter((candidate) => !isPathInsideRoot(candidate, sourceRoot)); - const candidatePatterns = entries - .filter( - (entry) => entry.relativePath.includes("*") && getRecursiveDirectoryPath(entry) === null, - ) - .map((entry) => entry.relativePath); - const candidateCollection = - candidatePatterns.length > 0 - ? await collectWorktreeIncludeCandidates({ - sourceRoot, - excludedSourceRoots, - patterns: candidatePatterns, - }) - : { candidates: [], errorsByPattern: new Map() }; - const materializations: WorktreeIncludeMaterialization[] = []; - - for (const entry of entries) { - const matchedPaths = resolveEntryMatches({ entry, candidates: candidateCollection.candidates }); - const candidateError = candidateCollection.errorsByPattern.get(entry.relativePath); - if (candidateError !== undefined) { - skipped.push(toSkippedEntry(entry, candidateError)); - if (matchedPaths.length === 0) { - continue; - } - } - if (matchedPaths.length === 0) { - skipped.push(toSkippedEntry(entry, noMatchError(entry))); - continue; - } - - for (const relativePath of matchedPaths) { - try { - const resolved = await resolveSourceMaterialization({ - entry, - excludedSourceRoots, - relativePath, - sourceRoot, - }); - materializations.push(resolved.materialization); - } catch (error) { - if (!isWorktreeIncludeMaterializationError(error)) { - throw error; - } - skipped.push(toSkippedEntry(entry, error)); - } - } - } - - const normalized = normalizeMaterializations(materializations); - - return { - excludedSourceRoots, - materializations: normalized.materializations, - skipped: [...skipped, ...normalized.skipped], - sourceRoot, - }; -} - -async function canonicalizeExistingPathPrefix(path: string): Promise { - const absolutePath = resolve(path); - const missingSegments: string[] = []; - let existingPath = absolutePath; - - while (true) { - try { - return join(await realpath(existingPath), ...missingSegments); - } catch (error) { - if (getErrorCode(error) !== "ENOENT" && getErrorCode(error) !== "ENOTDIR") { - throw error; - } - - const parentPath = dirname(existingPath); - if (parentPath === existingPath) { - return absolutePath; - } - missingSegments.unshift(pathBasename(existingPath)); - existingPath = parentPath; - } - } -} - -export async function materializeWorktreeIncludePlan( - options: MaterializeWorktreeIncludePlanOptions, -): Promise { - const skipped: WorktreeIncludeSkippedEntry[] = []; - let materialized = 0; - if (options.plan.materializations.length === 0) { - return { materialized, skipped }; - } - - const worktreeRoot = await realpath(options.worktreeRoot); - for (const materialization of options.plan.materializations) { - const entry = { - lineNumber: materialization.lineNumber, - mode: materialization.mode, - raw: materialization.raw, - relativePath: materialization.relativePath, - }; - let resolved: ResolvedWorktreeIncludeMaterialization; - try { - resolved = await resolveSourceMaterialization({ - entry, - excludedSourceRoots: options.plan.excludedSourceRoots, - relativePath: materialization.relativePath, - sourceRoot: options.plan.sourceRoot, - }); - } catch (error) { - if (isSkippableWorktreeIncludeError(error) || getErrorCode(error) !== null) { - skipped.push(toSkippedEntry(entry, error)); - continue; - } - throw error; - } - if (resolved.materialization.sourceKind !== materialization.sourceKind) { - skipped.push( - toSkippedEntry( - entry, - new WorktreeIncludeError( - "source_changed", - `Source for .worktreeinclude entry '${materialization.raw}' changed type before it could be materialized`, - ), - ), - ); - continue; - } - - let staged: StagedWorktreeIncludeMaterialization | null = null; - let createdDestinationParents: string[] = []; - try { - const destinationPath = getDestinationPath({ - worktreeRoot, - relativePath: resolved.materialization.relativePath, - }); - if ( - !(await preflightDestination({ - worktreeRoot, - resolved, - })) - ) { - materialized++; - continue; - } - - staged = await stageMaterialization({ - destinationPath, - resolved, - worktreeRoot, - }); - createdDestinationParents = await ensureDestinationParent({ - worktreeRoot, - relativePath: resolved.materialization.relativePath, - }); - if ( - !(await preflightDestination({ - worktreeRoot, - resolved, - })) - ) { - await cleanupStagingDirectory(staged.directoryPath); - staged = null; - await cleanupCreatedDestinationParents(createdDestinationParents); - materialized++; - continue; - } - - const destinationStats = await lstatIfExists(destinationPath); - if (resolved.materialization.mode === "copy" && destinationStats !== null) { - await copyStagedMaterializationToExistingDestination({ - destinationPath, - resolved, - staged, - }); - } else { - await rename(staged.entryPath, destinationPath); - } - - await cleanupStagingDirectory(staged.directoryPath); - staged = null; - materialized++; - } catch (error) { - if (error instanceof WorktreeIncludeCleanupError) { - throw error; - } - if (staged !== null) { - await cleanupStagingDirectory(staged.directoryPath); - } - await cleanupCreatedDestinationParents(createdDestinationParents); - if (isWorktreeIncludeMaterializationError(error)) { - skipped.push(toSkippedEntry(entry, error)); - continue; - } - throw error; - } - } - - return { materialized, skipped }; -} - -async function stageMaterialization(options: { - destinationPath: string; - resolved: ResolvedWorktreeIncludeMaterialization; - worktreeRoot: string; -}): Promise { - const directoryPath = await mkdtemp(join(options.worktreeRoot, ".paseo-worktreeinclude-")); - const entryPath = join(directoryPath, "entry"); - try { - if (options.resolved.materialization.mode === "copy") { - await copyMaterializationPath({ - destinationPath: entryPath, - sourceKind: options.resolved.materialization.sourceKind, - sourcePath: options.resolved.sourcePath, - }); - if (options.resolved.materialization.sourceKind === "directory") { - const stagedStats = await lstat(entryPath); - if (!stagedStats.isDirectory()) { - throw new WorktreeIncludeError( - "source_changed", - `.worktreeinclude entry '${options.resolved.materialization.raw}' changed type while it was staged`, - ); - } - await assertCopyDirectorySafe({ - entry: options.resolved.materialization, - sourcePath: entryPath, - }); - } - } else { - await createMaterializationSymlink({ - destinationPath: entryPath, - linkDestinationPath: options.destinationPath, - resolved: options.resolved, - }); - } - return { directoryPath, entryPath }; - } catch (error) { - await cleanupStagingDirectory(directoryPath); - throw error; - } -} - -async function copyStagedMaterializationToExistingDestination(options: { - destinationPath: string; - resolved: ResolvedWorktreeIncludeMaterialization; - staged: StagedWorktreeIncludeMaterialization; -}): Promise { - const backupPath = join(options.staged.directoryPath, "backup"); - await rename(options.destinationPath, backupPath); - try { - await rename(options.staged.entryPath, options.destinationPath); - } catch (error) { - await restoreCopiedDestination({ - backupPath, - destinationPath: options.destinationPath, - }); - throw error; - } -} - -async function copyMaterializationPath(options: { - destinationPath: string; - sourceKind: WorktreeIncludeSourceKind; - sourcePath: string; -}): Promise { - if (options.sourceKind === "file") { - await copyFile(options.sourcePath, options.destinationPath); - return; - } - await cp(options.sourcePath, options.destinationPath, { - recursive: true, - force: true, - dereference: false, - }); -} - -async function restoreCopiedDestination(options: { - backupPath: string; - destinationPath: string; -}): Promise { - try { - const destinationStats = await lstatIfExists(options.destinationPath); - if (destinationStats !== null) { - if (destinationStats.isSymbolicLink()) { - throw new Error("destination changed while restoring a failed materialization"); - } - await rm(options.destinationPath, { - recursive: destinationStats.isDirectory(), - force: true, - }); - } - await rename(options.backupPath, options.destinationPath); - } catch (error) { - throw new WorktreeIncludeCleanupError( - `Unable to restore .worktreeinclude destination '${options.destinationPath}' after a failed materialization`, - error, - ); - } -} - -async function cleanupStagingDirectory(directoryPath: string): Promise { - try { - await rm(directoryPath, { recursive: true, force: true }); - } catch (error) { - throw new WorktreeIncludeCleanupError( - `Unable to clean up .worktreeinclude staging directory '${directoryPath}'`, - error, - ); - } -} - -async function cleanupCreatedDestinationParents(createdPaths: string[]): Promise { - for (const path of createdPaths.toReversed()) { - try { - await rmdir(path); - } catch (error) { - const code = getErrorCode(error); - if (code === "ENOENT" || code === "ENOTEMPTY" || code === "EEXIST") { - continue; - } - throw new WorktreeIncludeCleanupError( - `Unable to clean up .worktreeinclude destination directory '${path}'`, - error, - ); - } - } -} - -async function readWorktreeIncludeEntries( - sourceRoot: string, -): Promise { - let contents: string; - try { - contents = await readFile(join(sourceRoot, WORKTREE_INCLUDE_FILE_NAME), "utf8"); - } catch (error) { - if (getErrorCode(error) === "ENOENT") { - return { entries: [], skipped: [] }; - } - throw error; - } - - const entries: WorktreeIncludeEntry[] = []; - const skipped: WorktreeIncludeSkippedEntry[] = []; - - for (const [index, sourceLine] of contents.split(/\r?\n/).entries()) { - const lineNumber = index + 1; - const line = sourceLine.trim(); - if (line.length === 0) { - continue; - } - - if (line.startsWith("#")) { - continue; - } - - try { - entries.push(parseWorktreeIncludeEntry({ line, lineNumber })); - } catch (error) { - if (!isSkippableWorktreeIncludeError(error)) { - throw error; - } - skipped.push(toSkippedEntry({ lineNumber, raw: line }, error)); - } - } - - return { entries, skipped }; -} - -function parseWorktreeIncludeEntry(options: { - line: string; - lineNumber: number; -}): WorktreeIncludeEntry { - let mode: WorktreeIncludeMode = "copy"; - let path = options.line; - const separatorIndex = options.line.search(/\s/); - - if (separatorIndex === -1) { - if (options.line === "copy" || options.line === "symlink") { - throw new WorktreeIncludeError( - "invalid_entry", - `.worktreeinclude ${options.line} entry on line ${options.lineNumber} requires a path`, - ); - } - } else { - const verb = options.line.slice(0, separatorIndex); - if (verb === "copy" || verb === "symlink") { - mode = verb; - path = options.line.slice(separatorIndex).trim(); - } - } - - return { - lineNumber: options.lineNumber, - mode, - raw: options.line, - relativePath: normalizeRelativePath({ entry: path, lineNumber: options.lineNumber }), - }; -} - -function normalizeRelativePath(options: { entry: string; lineNumber: number }): string { - const fail = (reason: string): never => { - throw new WorktreeIncludeError( - "invalid_entry", - `Invalid .worktreeinclude entry '${options.entry}' on line ${options.lineNumber}: ${reason}`, - ); - }; - - if ( - options.entry.includes("\0") || - options.entry.startsWith("/") || - options.entry.startsWith("\\") || - isAbsolute(options.entry) || - win32.isAbsolute(options.entry) || - /^[A-Za-z]:/.test(options.entry) - ) { - fail("absolute paths are not allowed"); - } - - const segments = options.entry - .split(/[\\/]+/) - .filter((segment) => segment.length > 0 && segment !== "."); - if (segments.length === 0) { - fail("path must not be empty"); - } - - for (const segment of segments) { - if (segment === "..") { - fail("parent-directory segments are not allowed"); - } - if (segment.toLowerCase() === ".git") { - fail("git metadata cannot be materialized"); - } - if (segment.includes(":") || /[. ]$/.test(segment) || isWindowsReservedSegment(segment)) { - fail("path is not portable to Windows"); - } - } - - return segments.join("/"); -} - -function isWindowsReservedSegment(segment: string): boolean { - const basename = segment.split(".", 1)[0]?.toLowerCase() ?? ""; - return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/.test(basename); -} - -function getRecursiveDirectoryPath(entry: WorktreeIncludeEntry): string | null { - const segments = entry.relativePath.split("/"); - if ( - segments.length < 2 || - segments.at(-1) !== "**" || - segments.slice(0, -1).some((segment) => segment.includes("*")) - ) { - return null; - } - return segments.slice(0, -1).join("/"); -} - -async function collectWorktreeIncludeCandidates(options: { - excludedSourceRoots: string[]; - patterns: string[]; - sourceRoot: string; -}): Promise { - const candidates: string[] = []; - const errorsByPattern = new Map(); - - async function visit( - directoryPath: string, - directorySegments: string[], - patterns: string[], - ancestorCanonicalDirectories: ReadonlySet, - ): Promise { - let entries: Dirent[]; - try { - entries = await readdir(directoryPath, { withFileTypes: true }); - } catch (error) { - if (getErrorCode(error) === null) { - throw error; - } - for (const pattern of patterns) { - errorsByPattern.set(pattern, error); - } - return; - } - for (const entry of entries) { - if (entry.name.toLowerCase() === ".git") { - continue; - } - - const pathSegments = [...directorySegments, entry.name]; - const sourcePath = join(options.sourceRoot, ...pathSegments); - if ( - options.excludedSourceRoots.some((excludedRoot) => - isPathInsideRoot(excludedRoot, sourcePath), - ) - ) { - continue; - } - - const relativePath = pathSegments.join("/"); - if (patterns.some((pattern) => worktreeIncludeGlobMatches(pattern, relativePath))) { - candidates.push(relativePath); - } - const descendantPatterns = patterns.filter((pattern) => - canGlobMatchDescendant(pattern, pathSegments), - ); - if ((entry.isDirectory() || entry.isSymbolicLink()) && descendantPatterns.length > 0) { - try { - const canonicalDirectory = await realpath(sourcePath); - const canonicalStats = await lstat(canonicalDirectory); - const canonicalRelativePath = relative(options.sourceRoot, canonicalDirectory); - const isGitMetadata = canonicalRelativePath - .split(/[\\/]/) - .some((segment) => segment.toLowerCase() === ".git"); - const overlapsProtectedRoot = options.excludedSourceRoots.some( - (excludedRoot) => - isPathInsideRoot(excludedRoot, canonicalDirectory) || - isPathInsideRoot(canonicalDirectory, excludedRoot), - ); - if ( - !canonicalStats.isDirectory() || - !isPathInsideRoot(options.sourceRoot, canonicalDirectory) || - isGitMetadata || - overlapsProtectedRoot || - ancestorCanonicalDirectories.has(canonicalDirectory) - ) { - continue; - } - await visit( - sourcePath, - pathSegments, - descendantPatterns, - new Set([...ancestorCanonicalDirectories, canonicalDirectory]), - ); - } catch (error) { - if (getErrorCode(error) === null) { - throw error; - } - for (const pattern of descendantPatterns) { - errorsByPattern.set(pattern, error); - } - } - } - } - } - - await visit( - options.sourceRoot, - [], - options.patterns, - new Set([await realpath(options.sourceRoot)]), - ); - return { candidates: candidates.sort(), errorsByPattern }; -} - -function canGlobMatchDescendant(pattern: string, directorySegments: string[]): boolean { - const patternSegments = pattern.split("/"); - const cache = new Map(); - - function match(patternIndex: number, directoryIndex: number): boolean { - const cacheKey = `${patternIndex}:${directoryIndex}`; - const cached = cache.get(cacheKey); - if (cached !== undefined) { - return cached; - } - - const patternSegment = patternSegments[patternIndex]; - let result: boolean; - if (directoryIndex === directorySegments.length) { - result = patternSegment !== undefined; - } else if (patternSegment === "**") { - result = match(patternIndex + 1, directoryIndex) || match(patternIndex, directoryIndex + 1); - } else { - const directorySegment = directorySegments[directoryIndex]; - result = - patternSegment !== undefined && - segmentGlobMatches(patternSegment, directorySegment) && - match(patternIndex + 1, directoryIndex + 1); - } - - cache.set(cacheKey, result); - return result; - } - - return match(0, 0); -} - -function resolveEntryMatches(options: { - candidates: string[]; - entry: WorktreeIncludeEntry; -}): string[] { - if (!options.entry.relativePath.includes("*")) { - return [options.entry.relativePath]; - } - - const recursiveDirectoryPath = getRecursiveDirectoryPath(options.entry); - if (recursiveDirectoryPath !== null) { - return [recursiveDirectoryPath]; - } - - return options.candidates.filter((candidate) => - worktreeIncludeGlobMatches(options.entry.relativePath, candidate), - ); -} - -function worktreeIncludeGlobMatches(pattern: string, candidate: string): boolean { - const patternSegments = pattern.split("/"); - const candidateSegments = candidate.split("/"); - const cache = new Map(); - - function match(patternIndex: number, candidateIndex: number): boolean { - const cacheKey = `${patternIndex}:${candidateIndex}`; - const cached = cache.get(cacheKey); - if (cached !== undefined) { - return cached; - } - - const patternSegment = patternSegments[patternIndex]; - let result: boolean; - if (patternSegment === undefined) { - result = candidateIndex === candidateSegments.length; - } else if (patternSegment === "**") { - result = - match(patternIndex + 1, candidateIndex) || - (candidateIndex < candidateSegments.length && match(patternIndex, candidateIndex + 1)); - } else { - const candidateSegment = candidateSegments[candidateIndex]; - result = - candidateSegment !== undefined && - segmentGlobMatches(patternSegment, candidateSegment) && - match(patternIndex + 1, candidateIndex + 1); - } - - cache.set(cacheKey, result); - return result; - } - - return match(0, 0); -} - -function segmentGlobMatches(pattern: string, value: string): boolean { - const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, "[^/]*"); - return new RegExp(`^${escaped}$`).test(value); -} - -async function resolveSourceMaterialization(options: { - entry: WorktreeIncludeEntry; - excludedSourceRoots: string[]; - relativePath: string; - sourceRoot: string; -}): Promise { - const requestedSourcePath = join(options.sourceRoot, ...options.relativePath.split("/")); - const sourcePath = await realpathSourcePath(requestedSourcePath, options.entry); - assertSourcePathIsSafe({ - entry: options.entry, - excludedSourceRoots: options.excludedSourceRoots, - sourcePath, - sourceRoot: options.sourceRoot, - }); - - const sourceKind = await getCanonicalSourceKind({ - entry: options.entry, - sourcePath, - }); - if ( - getRecursiveDirectoryPath(options.entry) === options.relativePath && - sourceKind !== "directory" - ) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} requires a directory`, - ); - } - if (sourceKind === "directory") { - if (options.entry.mode === "copy") { - await assertCopyDirectorySafe({ - entry: options.entry, - sourcePath, - }); - } else { - await assertSymlinkDirectorySafe({ - entry: options.entry, - excludedSourceRoots: options.excludedSourceRoots, - sourcePath, - sourceRoot: options.sourceRoot, - }); - } - } - - return { - materialization: { - lineNumber: options.entry.lineNumber, - mode: options.entry.mode, - raw: options.entry.raw, - relativePath: options.relativePath, - sourceKind, - }, - sourcePath, - }; -} - -function assertSourcePathIsSafe(options: { - entry: WorktreeIncludeEntry; - excludedSourceRoots: string[]; - sourcePath: string; - sourceRoot: string; -}): void { - if (!isPathInsideRoot(options.sourceRoot, options.sourcePath)) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves outside the source checkout`, - ); - } - - const canonicalRelativePath = relative(options.sourceRoot, options.sourcePath); - if (canonicalRelativePath.split(/[\\/]/).some((segment) => segment.toLowerCase() === ".git")) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves into Git metadata`, - ); - } - - const excludedRoot = options.excludedSourceRoots.find( - (candidate) => - isPathInsideRoot(options.sourcePath, candidate) || - isPathInsideRoot(candidate, options.sourcePath), - ); - if (excludedRoot === undefined) { - return; - } - - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} overlaps with a protected worktree path`, - ); -} - -async function getCanonicalSourceKind(options: { - entry: WorktreeIncludeEntry; - sourcePath: string; -}): Promise { - const stats = await lstatSourcePath(options.sourcePath, options.entry); - if (stats.isSymbolicLink()) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} changed while its source path was resolved`, - ); - } - if (!stats.isFile() && !stats.isDirectory()) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} must match a regular file or directory`, - ); - } - - return stats.isDirectory() ? "directory" : "file"; -} - -async function realpathSourcePath( - sourcePath: string, - entry: WorktreeIncludeEntry, -): Promise { - try { - return await realpath(sourcePath); - } catch (error) { - const code = getErrorCode(error); - if (code === "ENOENT" || code === "ENOTDIR") { - throw noMatchError(entry); - } - if (code === "ELOOP") { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${entry.raw}' on line ${entry.lineNumber} contains a symbolic-link loop`, - ); - } - throw error; - } -} - -async function lstatSourcePath(sourcePath: string, entry: WorktreeIncludeEntry): Promise { - try { - return await lstat(sourcePath); - } catch (error) { - if (getErrorCode(error) === "ENOENT" || getErrorCode(error) === "ENOTDIR") { - throw noMatchError(entry); - } - throw error; - } -} - -async function assertCopyDirectorySafe(options: { - entry: WorktreeIncludeEntry; - sourcePath: string; -}): Promise { - for (const name of await readdir(options.sourcePath)) { - if (name.toLowerCase() === ".git") { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains git metadata`, - ); - } - - const sourcePath = join(options.sourcePath, name); - const stats = await lstatSourcePath(sourcePath, options.entry); - if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains an unsupported file type or symbolic link`, - ); - } - if (stats.isDirectory()) { - await assertCopyDirectorySafe({ sourcePath, entry: options.entry }); - } - } -} - -async function assertSymlinkDirectorySafe(options: { - entry: WorktreeIncludeEntry; - excludedSourceRoots: string[]; - sourcePath: string; - sourceRoot: string; -}): Promise { - const visitedDirectories = new Set(); - - async function visit(directoryPath: string): Promise { - if (visitedDirectories.has(directoryPath)) { - return; - } - visitedDirectories.add(directoryPath); - - for (const name of await readdir(directoryPath)) { - if (name.toLowerCase() === ".git") { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains git metadata`, - ); - } - - const childPath = join(directoryPath, name); - const childStats = await lstatSourcePath(childPath, options.entry); - if (childStats.isSymbolicLink()) { - const linkedPath = await realpathSourcePath(childPath, options.entry); - assertSourcePathIsSafe({ - entry: options.entry, - excludedSourceRoots: options.excludedSourceRoots, - sourcePath: linkedPath, - sourceRoot: options.sourceRoot, - }); - const linkedKind = await getCanonicalSourceKind({ - entry: options.entry, - sourcePath: linkedPath, - }); - if (linkedKind === "directory") { - await visit(linkedPath); - } - continue; - } - - if (childStats.isDirectory()) { - await visit(childPath); - continue; - } - if (!childStats.isFile()) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains an unsupported file type`, - ); - } - } - } - - await visit(options.sourcePath); -} - -function normalizeMaterializations( - materializations: WorktreeIncludeMaterialization[], -): NormalizedWorktreeIncludeMaterializations { - const byPath = new Map(); - for (const materialization of materializations) { - const matches = byPath.get(materialization.relativePath) ?? []; - matches.push(materialization); - byPath.set(materialization.relativePath, matches); - } - - const skipped: WorktreeIncludeSkippedEntry[] = []; - const candidates: WorktreeIncludeMaterialization[] = []; - for (const [relativePath, matches] of byPath) { - if (new Set(matches.map((materialization) => materialization.mode)).size === 1) { - candidates.push(matches[0]!); - continue; - } - - const message = `.worktreeinclude entries for '${relativePath}' use both copy and symlink modes`; - skipped.push( - ...matches.map((materialization) => - toSkippedEntry(materialization, new WorktreeIncludeError("conflict", message)), - ), - ); - } - - const conflicted = new Set(); - const skippedConflictEntries = new Set(); - const skipConflict = (materialization: WorktreeIncludeMaterialization, message: string): void => { - conflicted.add(materialization); - if (skippedConflictEntries.has(materialization)) { - return; - } - skippedConflictEntries.add(materialization); - skipped.push(toSkippedEntry(materialization, new WorktreeIncludeError("conflict", message))); - }; - - for (let leftIndex = 0; leftIndex < candidates.length; leftIndex++) { - const left = candidates[leftIndex]!; - for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex++) { - const right = candidates[rightIndex]!; - let ancestor: WorktreeIncludeMaterialization | null = null; - if (isRelativePathAncestor(left.relativePath, right.relativePath)) { - ancestor = left; - } else if (isRelativePathAncestor(right.relativePath, left.relativePath)) { - ancestor = right; - } - if (ancestor === null || (left.mode === "copy" && right.mode === "copy")) { - continue; - } - - const descendant = ancestor === left ? right : left; - const message = `.worktreeinclude entries for '${ancestor.relativePath}' and '${descendant.relativePath}' overlap with a symlink`; - skipConflict(left, message); - skipConflict(right, message); - } - } - - const sorted = candidates - .filter((materialization) => !conflicted.has(materialization)) - .sort((left, right) => { - const depthDifference = - left.relativePath.split("/").length - right.relativePath.split("/").length; - return depthDifference === 0 - ? left.relativePath.localeCompare(right.relativePath) - : depthDifference; - }); - return { materializations: sorted, skipped }; -} - -function isRelativePathAncestor(ancestor: string, candidate: string): boolean { - return ancestor !== candidate && candidate.startsWith(`${ancestor}/`); -} - -async function preflightDestination(options: { - resolved: ResolvedWorktreeIncludeMaterialization; - worktreeRoot: string; -}): Promise { - const { materialization } = options.resolved; - const destinationPath = getDestinationPath({ - worktreeRoot: options.worktreeRoot, - relativePath: materialization.relativePath, - }); - const destinationStats = await lstatIfExists(destinationPath); - if (destinationStats === null) { - return true; - } - - if (destinationStats.isSymbolicLink()) { - if ( - materialization.mode === "symlink" && - (await isExpectedSymlink({ - destinationPath, - sourcePath: options.resolved.sourcePath, - })) - ) { - return false; - } - throw destinationConflict(materialization); - } - - if (materialization.mode === "symlink") { - throw destinationConflict(materialization); - } - - if ( - (materialization.sourceKind === "file" && !destinationStats.isFile()) || - (materialization.sourceKind === "directory" && !destinationStats.isDirectory()) - ) { - throw destinationConflict(materialization); - } - - if (materialization.sourceKind === "directory") { - await assertCopyDestinationTreeSafe({ - sourcePath: options.resolved.sourcePath, - destinationPath, - materialization, - }); - } - return true; -} - -function getDestinationPath(options: { relativePath: string; worktreeRoot: string }): string { - const destinationPath = join(options.worktreeRoot, ...options.relativePath.split("/")); - if (!isPathInsideRoot(options.worktreeRoot, destinationPath)) { - throw new WorktreeIncludeError( - "invalid_entry", - `.worktreeinclude entry '${options.relativePath}' resolves outside the worktree`, - ); - } - return destinationPath; -} - -async function ensureDestinationParent(options: { - relativePath: string; - worktreeRoot: string; -}): Promise { - const parentSegments = options.relativePath.split("/").slice(0, -1); - let currentPath = options.worktreeRoot; - const createdPaths: string[] = []; - try { - for (const segment of parentSegments) { - currentPath = join(currentPath, segment); - const stats = await lstatIfExists(currentPath); - if (stats === null) { - await mkdir(currentPath); - createdPaths.push(currentPath); - continue; - } - if (stats.isSymbolicLink() || !stats.isDirectory()) { - throw new WorktreeIncludeError( - "conflict", - `Refusing to materialize .worktreeinclude entry '${options.relativePath}' through '${currentPath}'`, - ); - } - } - } catch (error) { - await cleanupCreatedDestinationParents(createdPaths); - throw error; - } - return createdPaths; -} - -async function assertCopyDestinationTreeSafe(options: { - destinationPath: string; - materialization: WorktreeIncludeMaterialization; - sourcePath: string; -}): Promise { - for (const name of await readdir(options.sourcePath)) { - const sourceChildPath = join(options.sourcePath, name); - const sourceStats = await lstat(sourceChildPath); - if (sourceStats.isSymbolicLink()) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.materialization.relativePath}' contains a symbolic link`, - ); - } - - const destinationChildPath = join(options.destinationPath, name); - const destinationStats = await lstatIfExists(destinationChildPath); - if (destinationStats === null) { - continue; - } - if (destinationStats.isSymbolicLink()) { - throw destinationConflict(options.materialization); - } - if ( - (sourceStats.isFile() && !destinationStats.isFile()) || - (sourceStats.isDirectory() && !destinationStats.isDirectory()) - ) { - throw destinationConflict(options.materialization); - } - if (sourceStats.isDirectory()) { - await assertCopyDestinationTreeSafe({ - sourcePath: sourceChildPath, - destinationPath: destinationChildPath, - materialization: options.materialization, - }); - } - } -} - -async function createMaterializationSymlink(options: { - destinationPath: string; - linkDestinationPath?: string; - resolved: ResolvedWorktreeIncludeMaterialization; -}): Promise { - if (process.platform !== "win32") { - const target = relative( - dirname(options.linkDestinationPath ?? options.destinationPath), - options.resolved.sourcePath, - ); - await symlink(target, options.destinationPath); - return; - } - - let type: "dir" | "file" | "junction" = "file"; - if (options.resolved.materialization.sourceKind === "directory") { - type = isWindowsNetworkPath(options.resolved.sourcePath) ? "dir" : "junction"; - } - try { - await symlink(options.resolved.sourcePath, options.destinationPath, type); - } catch (error) { - throw toWindowsSymlinkError({ error, entry: options.resolved.materialization }); - } -} - -function isWindowsSymlinkPrivilegeError(error: unknown): boolean { - const code = getErrorCode(error); - return code === "EACCES" || code === "EPERM" || code === "ENOTSUP"; -} - -function isWindowsNetworkPath(path: string): boolean { - return path.startsWith("\\\\"); -} - -function toWindowsSymlinkError(options: { - entry: WorktreeIncludeMaterialization; - error: unknown; -}): Error { - if (!isWindowsSymlinkPrivilegeError(options.error)) { - return options.error instanceof Error ? options.error : new Error(String(options.error)); - } - return new WorktreeIncludeError( - "windows_symlink_unavailable", - `Unable to create a Windows symlink for .worktreeinclude entry '${options.entry.relativePath}'. Enable Developer Mode or use copy ${options.entry.relativePath}.`, - ); -} - -async function isExpectedSymlink(options: { - destinationPath: string; - sourcePath: string; -}): Promise { - try { - const [destinationTarget, sourceTarget] = await Promise.all([ - realpath(options.destinationPath), - realpath(options.sourcePath), - ]); - return areEquivalentPaths(destinationTarget, sourceTarget); - } catch { - return false; - } -} - -async function lstatIfExists(path: string): Promise { - try { - return await lstat(path); - } catch (error) { - if (getErrorCode(error) === "ENOENT") { - return null; - } - throw error; - } -} - -function destinationConflict( - materialization: WorktreeIncludeMaterialization, -): WorktreeIncludeError { - return new WorktreeIncludeError( - "conflict", - `.worktreeinclude entry '${materialization.relativePath}' on line ${materialization.lineNumber} conflicts with the new worktree`, - ); -} - -function noMatchError(entry: WorktreeIncludeEntry): WorktreeIncludeError { - return new WorktreeIncludeError( - "missing_source", - `No paths matched .worktreeinclude entry '${entry.raw}' on line ${entry.lineNumber}`, - ); -} - -function toSkippedEntry( - entry: Pick, - error: unknown, -): WorktreeIncludeSkippedEntry { - return { - lineNumber: entry.lineNumber, - message: error instanceof Error ? error.message : String(error), - raw: entry.raw, - reason: getSkipReason(error), - }; -} - -function getSkipReason(error: unknown): WorktreeIncludeSkipReason { - if (!(error instanceof WorktreeIncludeError)) { - return "materialization"; - } - switch (error.code) { - case "conflict": - return "conflict"; - case "invalid_entry": - return "invalid"; - case "windows_symlink_unavailable": - return "materialization"; - case "missing_source": - return "missing"; - case "source_changed": - return "source_changed"; - case "unsupported_source": - return "unsafe"; - } -} - -function isSkippableWorktreeIncludeError(error: unknown): error is WorktreeIncludeError { - return error instanceof WorktreeIncludeError && error.code !== "windows_symlink_unavailable"; -} - -function isWorktreeIncludeMaterializationError(error: unknown): boolean { - return error instanceof WorktreeIncludeError || getErrorCode(error) !== null; -} - -function getErrorCode(error: unknown): string | null { - if (typeof error !== "object" || error === null || !("code" in error)) { - return null; - } - const { code } = error; - return typeof code === "string" ? code : null; -} diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 53a555cf4d..d895df5395 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -34,7 +34,6 @@ import { writeFileSync, readFileSync, chmodSync, - lstatSync, } from "fs"; import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; @@ -363,68 +362,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(metadata).toMatchObject({ baseRefName: "main" }); }); - it("removes fetched branches when include planning fails", async () => { - const remoteDir = join(tempDir, "remote.git"); - const remoteCloneDir = join(tempDir, "remote-clone"); - execFileSync("git", ["clone", "--bare", repoDir, remoteDir]); - execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); - - execFileSync("git", ["clone", remoteDir, remoteCloneDir]); - execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir }); - execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir }); - execFileSync("git", ["checkout", "-b", "contributor/cleanup"], { cwd: remoteCloneDir }); - writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n"); - execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], { - cwd: remoteCloneDir, - }); - const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir }) - .toString() - .trim(); - execFileSync("git", ["push", "origin", "contributor/cleanup"], { cwd: remoteCloneDir }); - execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/44/head", prHead]); - mkdirSync(join(repoDir, ".worktreeinclude")); - - await expect( - createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "pr-44-cleanup", - source: { - kind: "checkout-github-pr", - githubPrNumber: 44, - headRef: "contributor/cleanup", - baseRefName: "main", - }, - runSetup: false, - paseoHome, - }), - ).rejects.toMatchObject({ code: "EISDIR" }); - - expect(() => - execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], { - cwd: repoDir, - stdio: "pipe", - }), - ).toThrow(); - - await expect( - createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "branch-cleanup", - source: { kind: "checkout-branch", branchName: "contributor/cleanup" }, - runSetup: false, - paseoHome, - }), - ).rejects.toMatchObject({ code: "EISDIR" }); - - expect(() => - execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], { - cwd: repoDir, - stdio: "pipe", - }), - ).toThrow(); - }); - it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => { const remoteDir = join(tempDir, "remote.git"); const remoteCloneDir = join(tempDir, "remote-clone"); @@ -1125,241 +1062,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); - it("materializes copies and symlinks before setup, then removes only the new worktree links", async () => { - writeFileSync( - join(repoDir, ".gitignore"), - [".copy.env", "copy-cache/", "linked-file.txt", "linked-state", "setup.log", ""].join("\n"), - ); - writeFileSync( - join(repoDir, "paseo.json"), - JSON.stringify({ - worktree: { - setup: [ - "test -f .copy.env", - "test -L linked-file.txt", - "test -L linked-state", - "cat linked-state/state.txt > setup.log", - ], - }, - }), - ); - execFileSync("git", ["add", ".gitignore", "paseo.json"], { cwd: repoDir }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include fixture"], { - cwd: repoDir, - }); - - writeFileSync( - join(repoDir, ".worktreeinclude"), - [".copy.env", "copy-cache/**", "symlink linked-file.txt", "symlink linked-state", ""].join( - "\n", - ), - ); - writeFileSync(join(repoDir, ".copy.env"), "copy-v1\n"); - mkdirSync(join(repoDir, "copy-cache"), { recursive: true }); - writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v1\n"); - writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v1\n"); - mkdirSync(join(repoDir, "linked-state"), { recursive: true }); - writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v1\n"); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "include-links", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/include-links" }, - runSetup: true, - paseoHome, - }); - - expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe( - "linked-state-v1\n", - ); - expect(lstatSync(join(result.worktreePath, ".copy.env")).isSymbolicLink()).toBe(false); - expect(lstatSync(join(result.worktreePath, "copy-cache")).isSymbolicLink()).toBe(false); - expect(lstatSync(join(result.worktreePath, "linked-file.txt")).isSymbolicLink()).toBe(true); - expect(lstatSync(join(result.worktreePath, "linked-state")).isSymbolicLink()).toBe(true); - expect( - execFileSync("git", ["status", "--porcelain"], { - cwd: result.worktreePath, - encoding: "utf8", - }), - ).toBe(""); - - writeFileSync(join(repoDir, ".copy.env"), "copy-v2\n"); - writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v2\n"); - writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v2\n"); - writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v2\n"); - - expect(readFileSync(join(result.worktreePath, ".copy.env"), "utf8")).toBe("copy-v1\n"); - expect(readFileSync(join(result.worktreePath, "copy-cache", "state.txt"), "utf8")).toBe( - "copy-cache-v1\n", - ); - expect(readFileSync(join(result.worktreePath, "linked-file.txt"), "utf8")).toBe( - "linked-file-v2\n", - ); - expect(readFileSync(join(result.worktreePath, "linked-state", "state.txt"), "utf8")).toBe( - "linked-state-v2\n", - ); - - await deletePaseoWorktree({ - cwd: repoDir, - worktreePath: result.worktreePath, - paseoHome, - }); - - expect(existsSync(result.worktreePath)).toBe(false); - expect(readFileSync(join(repoDir, "linked-file.txt"), "utf8")).toBe("linked-file-v2\n"); - expect(readFileSync(join(repoDir, "linked-state", "state.txt"), "utf8")).toBe( - "linked-state-v2\n", - ); - }); - - it("skips missing includes and materializes paths that exist", async () => { - const projectHash = await deriveWorktreeProjectHash(repoDir); - const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "missing-include"); - writeFileSync( - join(repoDir, ".worktreeinclude"), - [".env", ".env.local", ".sops.yaml", ""].join("\n"), - ); - writeFileSync(join(repoDir, ".env"), "present\n"); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "missing-include", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" }, - runSetup: false, - paseoHome, - }); - - expect(result.worktreePath).toBe(expectedWorktreePath); - expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n"); - expect(existsSync(join(result.worktreePath, ".env.local"))).toBe(false); - expect(existsSync(join(result.worktreePath, ".sops.yaml"))).toBe(false); - expect( - execFileSync("git", ["worktree", "list", "--porcelain"], { - cwd: repoDir, - encoding: "utf8", - }), - ).toContain(expectedWorktreePath); - }); - - it("skips checkout-local worktree storage and creates the remaining includes", async () => { - const checkoutLocalPaseoHome = join(repoDir, ".dev", "paseo-home"); - const projectHash = await deriveWorktreeProjectHash(repoDir); - const expectedWorktreePath = join( - checkoutLocalPaseoHome, - "worktrees", - projectHash, - "protected-include", - ); - mkdirSync(join(checkoutLocalPaseoHome, "worktrees", projectHash), { recursive: true }); - writeFileSync(join(repoDir, ".worktreeinclude"), [".dev/**", ".env", ""].join("\n")); - writeFileSync(join(repoDir, ".env"), "present\n"); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "protected-include", - source: { - kind: "branch-off", - baseBranch: "main", - branchName: "feature/protected-include", - }, - runSetup: false, - paseoHome: checkoutLocalPaseoHome, - }); - - expect(result.worktreePath).toBe(expectedWorktreePath); - expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n"); - expect(result.worktreeIncludeSummary?.skipped).toEqual([ - expect.objectContaining({ raw: ".dev/**", reason: "unsafe" }), - ]); - expect( - execFileSync("git", ["worktree", "list", "--porcelain"], { - cwd: repoDir, - encoding: "utf8", - }), - ).toContain(expectedWorktreePath); - expect( - execFileSync("git", ["branch", "--list", "feature/protected-include"], { - cwd: repoDir, - encoding: "utf8", - }).trim(), - ).toContain("feature/protected-include"); - }); - - it("keeps includes when branching from a Paseo-managed worktree", async () => { - writeFileSync(join(repoDir, ".gitignore"), ".env\n"); - writeFileSync(join(repoDir, ".worktreeinclude"), ".env\n"); - execFileSync("git", ["add", ".gitignore", ".worktreeinclude"], { cwd: repoDir }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include config"], { - cwd: repoDir, - }); - writeFileSync(join(repoDir, ".env"), "source\n"); - - const sourceWorktree = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "include-source", - source: { - kind: "branch-off", - baseBranch: "main", - branchName: "feature/include-source", - }, - runSetup: false, - paseoHome, - }); - const nestedWorktree = await createLegacyWorktreeForTest({ - cwd: sourceWorktree.worktreePath, - worktreeSlug: "include-nested", - source: { - kind: "branch-off", - baseBranch: "feature/include-source", - branchName: "feature/include-nested", - }, - runSetup: false, - paseoHome, - }); - - expect(readFileSync(join(sourceWorktree.worktreePath, ".env"), "utf8")).toBe("source\n"); - expect(readFileSync(join(nestedWorktree.worktreePath, ".env"), "utf8")).toBe("source\n"); - expect(nestedWorktree.worktreeIncludeSummary?.skipped).toEqual([]); - }); - - it("skips a symlink include conflict and keeps the new worktree", async () => { - const projectHash = await deriveWorktreeProjectHash(repoDir); - const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict"); - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} })); - writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n"); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "include-conflict", - source: { - kind: "branch-off", - baseBranch: "main", - branchName: "feature/include-conflict", - }, - runSetup: false, - paseoHome, - }); - - expect(result.worktreePath).toBe(expectedWorktreePath); - expect(existsSync(expectedWorktreePath)).toBe(true); - expect(lstatSync(join(expectedWorktreePath, "paseo.json")).isSymbolicLink()).toBe(false); - expect(result.worktreeIncludeSummary?.skipped).toEqual([ - expect.objectContaining({ raw: "symlink paseo.json", reason: "conflict" }), - ]); - expect( - execFileSync("git", ["worktree", "list", "--porcelain"], { - cwd: repoDir, - encoding: "utf8", - }), - ).toContain(expectedWorktreePath); - expect( - execFileSync("git", ["branch", "--list", "feature/include-conflict"], { - cwd: repoDir, - encoding: "utf8", - }).trim(), - ).toContain("feature/include-conflict"); - }); - it("creates a worktree without error when no paseo.json exists in the main repo", async () => { const result = await createLegacyWorktreeForTest({ cwd: repoDir, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index f46ae6c54b..4ab641806b 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; import { copyFile, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; -import { createHash, randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; import stripAnsi from "strip-ansi"; import { buildStringCommandShellInvocation, @@ -36,11 +36,6 @@ import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; -import { - materializeWorktreeIncludePlan, - readWorktreeIncludePlan, - type WorktreeIncludeSummary, -} from "./worktree-include.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -54,10 +49,6 @@ export interface WorktreeConfig { worktreePath: string; } -export interface CreatedWorktreeConfig extends WorktreeConfig { - worktreeIncludeSummary: WorktreeIncludeSummary; -} - export interface WorktreeRuntimeEnv { [key: string]: string; PASEO_SOURCE_CHECKOUT_PATH: string; @@ -1170,67 +1161,13 @@ export async function deletePaseoWorktree({ } } -export interface RollbackCreatedPaseoWorktreeOptions extends DeletePaseoWorktreeOptions { - createdBranchName?: string; - expectedOid?: string; -} - -async function removeCreatedWorktreeBranch(options: { - createdBranchName?: string; - expectedOid?: string; - cwd?: string | null; -}): Promise { - if (!options.createdBranchName || !options.cwd) { - return; - } - if (!(await localBranchExists(options.cwd, options.createdBranchName))) { - return; - } - if (options.expectedOid) { - await runGitCommand( - ["update-ref", "-d", `refs/heads/${options.createdBranchName}`, options.expectedOid], - { cwd: options.cwd }, - ); - } else { - await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], { - cwd: options.cwd, - }); - } -} - -async function rollbackCreatedWorktreeBranch( - options: { - createdBranchName?: string; - expectedOid?: string; - cwd: string; - }, - cause: unknown, -): Promise { - let cleanupError: unknown; - try { - await removeCreatedWorktreeBranch(options); - } catch (error) { - cleanupError = error; - } - if (cleanupError) { - const failure = new Error( - `${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, - { cause }, - ); - Object.assign(failure, { cleanupError }); - throw failure; - } - throw cause; -} - export async function rollbackCreatedPaseoWorktree( - options: RollbackCreatedPaseoWorktreeOptions, + options: DeletePaseoWorktreeOptions, cause: unknown, ): Promise { let cleanupError: unknown; try { await deletePaseoWorktree(options); - await removeCreatedWorktreeBranch(options); } catch (error) { cleanupError = error; } @@ -1296,100 +1233,50 @@ export const createWorktree = async ({ runSetup, paseoHome, worktreesRoot, -}: CreateWorktreeOptions): Promise => { +}: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); - const { worktreeIncludePlan, worktreePath } = await (async () => { - try { - const paseoWorktreesBaseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot }); - const paseoWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot); - const includePlan = await readWorktreeIncludePlan({ - sourceRoot: cwd, - excludedSourceRoots: [paseoWorktreesBaseRoot], - }); - const requestedWorktreePath = join(paseoWorktreesRoot, worktreeSlug); - mkdirSync(dirname(requestedWorktreePath), { recursive: true }); + let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); + mkdirSync(dirname(worktreePath), { recursive: true }); - // Also handle worktree path collision - let finalWorktreePath = requestedWorktreePath; - let pathSuffix = 1; - while (existsSync(finalWorktreePath)) { - finalWorktreePath = `${requestedWorktreePath}-${pathSuffix}`; - pathSuffix++; - } - - // Primitive owner for `git worktree add`; callers route through createWorktreeCore. - await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { - cwd, - timeout: 120_000, - }); - - return { - worktreeIncludePlan: includePlan, - worktreePath: normalizePathForOwnership(finalWorktreePath), - }; - } catch (error) { - return rollbackCreatedWorktreeBranch( - { - cwd, - createdBranchName: sourcePlan.createdBranchNameBeforeWorktreeAdd, - expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd, - }, - error, - ); - } - })(); - - let worktreeIncludeSummary: WorktreeIncludeSummary = { - materialized: 0, - skipped: [...worktreeIncludePlan.skipped], - }; - try { - if (sourcePlan.pushRemote) { - await configureWorktreePushRemote({ - cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.pushRemote, - }); - } - if (sourcePlan.trackingRemote) { - await configureWorktreeTrackingRemote({ - cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.trackingRemote, - }); - } - - writePaseoWorktreeMetadata(worktreePath, { - baseRefName: sourcePlan.metadataBaseRefName, - ...(sourcePlan.changeRequestLookupTarget - ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } - : {}), - }); - - await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); - const materialization = await materializeWorktreeIncludePlan({ - plan: worktreeIncludePlan, - worktreeRoot: worktreePath, - }); - worktreeIncludeSummary = { - materialized: materialization.materialized, - skipped: [...worktreeIncludePlan.skipped, ...materialization.skipped], - }; - } catch (error) { - await rollbackCreatedPaseoWorktree( - { - cwd, - worktreePath, - teardownCwds: [], - paseoHome, - worktreesBaseRoot: worktreesRoot, - createdBranchName: sourcePlan.createdBranchName, - expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd, - }, - error, - ); + // Also handle worktree path collision + let finalWorktreePath = worktreePath; + let pathSuffix = 1; + while (existsSync(finalWorktreePath)) { + finalWorktreePath = `${worktreePath}-${pathSuffix}`; + pathSuffix++; } + // Primitive owner for `git worktree add`; callers route through createWorktreeCore. + await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { + cwd, + timeout: 120_000, + }); + worktreePath = normalizePathForOwnership(finalWorktreePath); + + if (sourcePlan.pushRemote) { + await configureWorktreePushRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.pushRemote, + }); + } + if (sourcePlan.trackingRemote) { + await configureWorktreeTrackingRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.trackingRemote, + }); + } + + writePaseoWorktreeMetadata(worktreePath, { + baseRefName: sourcePlan.metadataBaseRefName, + ...(sourcePlan.changeRequestLookupTarget + ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } + : {}), + }); + + await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); + if (runSetup) { await runWorktreeSetupCommands({ worktreePath, @@ -1400,7 +1287,6 @@ export const createWorktree = async ({ return { branchName: sourcePlan.branchName, - worktreeIncludeSummary, worktreePath, }; }; @@ -1413,9 +1299,6 @@ interface ResolveWorktreeSourcePlanOptions { interface WorktreeSourcePlan { branchName: string; - createdBranchName?: string; - createdBranchNameBeforeWorktreeAdd?: string; - createdBranchOidBeforeWorktreeAdd?: string; metadataBaseRefName: string; changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget; addArguments: string[]; @@ -1431,11 +1314,6 @@ interface WorktreeSourcePlan { }; } -type ChangeRequestWorktreeSource = Extract< - WorktreeSource, - { kind: "checkout-change-request" | "checkout-github-pr" } ->; - async function resolveWorktreeSourcePlan({ cwd, source, @@ -1454,143 +1332,93 @@ async function resolveWorktreeSourcePlan({ return { branchName: newBranchName, - createdBranchName: newBranchName, metadataBaseRefName: normalizedBaseBranch, addArguments: ["-b", newBranchName, "--no-track", base], }; } - case "checkout-branch": - return resolveCheckoutBranchWorktreeSourcePlan({ cwd, branchName: source.branchName }); - case "checkout-change-request": - case "checkout-github-pr": - return resolveChangeRequestWorktreeSourcePlan({ cwd, source }); - } -} - -async function resolveCheckoutBranchWorktreeSourcePlan(options: { - branchName: string; - cwd: string; -}): Promise { - await validateExistingWorktreeBranchName(options.cwd, options.branchName); - const needsFetch = !(await localBranchExists(options.cwd, options.branchName)); - let createdBranchOid: string | undefined; - if (needsFetch) { - try { - createdBranchOid = await fetchNewLocalBranchAtomically({ - cwd: options.cwd, - localBranchName: options.branchName, - remoteName: "origin", - remoteRef: `refs/heads/${options.branchName}`, - }); - } catch { - throw new UnknownBranchError({ branchName: options.branchName, cwd: options.cwd }); - } - } - - try { - if (await isBranchCheckedOut(options.cwd, options.branchName)) { - throw new BranchAlreadyCheckedOutError(options.branchName); - } - } catch (error) { - if (!needsFetch) { - throw error; - } - return rollbackCreatedWorktreeBranch( - { - cwd: options.cwd, - createdBranchName: options.branchName, - expectedOid: createdBranchOid, - }, - error, - ); - } - - return { - branchName: options.branchName, - ...(needsFetch - ? { - createdBranchName: options.branchName, - createdBranchNameBeforeWorktreeAdd: options.branchName, - createdBranchOidBeforeWorktreeAdd: createdBranchOid, + case "checkout-branch": { + await validateExistingWorktreeBranchName(cwd, source.branchName); + if (!(await localBranchExists(cwd, source.branchName))) { + try { + await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], { + cwd, + timeout: 120_000, + }); + } catch { + throw new UnknownBranchError({ branchName: source.branchName, cwd }); } - : {}), - metadataBaseRefName: options.branchName, - addArguments: [options.branchName], - }; -} - -async function resolveChangeRequestWorktreeSourcePlan(options: { - cwd: string; - source: ChangeRequestWorktreeSource; -}): Promise { - const { cwd, source } = options; - const localBranchCandidate = source.localBranchName ?? source.headRef; - await validateExistingWorktreeBranchName(cwd, localBranchCandidate); - const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); - const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); - const changeRequestNumber = - source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber; - - let createdBranchOid: string | undefined; - try { - createdBranchOid = await fetchWorktreeCheckoutRefs({ - cwd, - localBranchName, - checkoutRefs: source.checkoutRefs ?? [ - { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, - ], - }); - const shouldTrackOriginHead = source.trackOriginHead === true; - const trackingRemote = shouldTrackOriginHead - ? await tryFetchWorktreeTrackingRemote({ - cwd, - remoteName: "origin", - headRef: source.headRef, - }) - : undefined; - const remotePlan: Pick = {}; - if (source.pushRemoteUrl) { - const remoteName = `paseo-pr-${changeRequestNumber}`; - remotePlan.pushRemote = { - name: remoteName, - url: source.pushRemoteUrl, - headRef: source.headRef, - track: true, - }; - } else if (shouldTrackOriginHead && localBranchName !== source.headRef) { - const originUrl = await getWorktreeRemotePushUrl(cwd, "origin"); - if (originUrl) { - remotePlan.pushRemote = { - name: `paseo-pr-${changeRequestNumber}`, - url: originUrl, - headRef: source.headRef, - track: false, - }; } - } - if (trackingRemote) { - remotePlan.trackingRemote = trackingRemote; - } + if (await isBranchCheckedOut(cwd, source.branchName)) { + throw new BranchAlreadyCheckedOutError(source.branchName); + } - return { - branchName: localBranchName, - createdBranchName: localBranchName, - createdBranchNameBeforeWorktreeAdd: localBranchName, - createdBranchOidBeforeWorktreeAdd: createdBranchOid, - metadataBaseRefName: normalizedBaseRefName, - changeRequestLookupTarget: { - headRef: source.headRef, - ...(source.headRepositoryOwner ? { headRepositoryOwner: source.headRepositoryOwner } : {}), - changeRequestNumber, - }, - addArguments: [localBranchName], - ...remotePlan, - }; - } catch (error) { - return rollbackCreatedWorktreeBranch( - { cwd, createdBranchName: localBranchName, expectedOid: createdBranchOid }, - error, - ); + return { + branchName: source.branchName, + metadataBaseRefName: source.branchName, + addArguments: [source.branchName], + }; + } + case "checkout-change-request": + case "checkout-github-pr": { + const localBranchCandidate = source.localBranchName ?? source.headRef; + await validateExistingWorktreeBranchName(cwd, localBranchCandidate); + const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); + const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); + const changeRequestNumber = + source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber; + await fetchWorktreeCheckoutRefs({ + cwd, + localBranchName, + checkoutRefs: source.checkoutRefs ?? [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + }); + const shouldTrackOriginHead = source.trackOriginHead === true; + const trackingRemote = shouldTrackOriginHead + ? await tryFetchWorktreeTrackingRemote({ + cwd, + remoteName: "origin", + headRef: source.headRef, + }) + : undefined; + const remotePlan: Pick = {}; + if (source.pushRemoteUrl) { + const remoteName = `paseo-pr-${changeRequestNumber}`; + remotePlan.pushRemote = { + name: remoteName, + url: source.pushRemoteUrl, + headRef: source.headRef, + track: true, + }; + } else if (shouldTrackOriginHead && localBranchName !== source.headRef) { + const originUrl = await getWorktreeRemotePushUrl(cwd, "origin"); + if (originUrl) { + remotePlan.pushRemote = { + name: `paseo-pr-${changeRequestNumber}`, + url: originUrl, + headRef: source.headRef, + track: false, + }; + } + } + if (trackingRemote) { + remotePlan.trackingRemote = trackingRemote; + } + + return { + branchName: localBranchName, + metadataBaseRefName: normalizedBaseRefName, + changeRequestLookupTarget: { + headRef: source.headRef, + ...(source.headRepositoryOwner + ? { headRepositoryOwner: source.headRepositoryOwner } + : {}), + changeRequestNumber, + }, + addArguments: [localBranchName], + ...remotePlan, + }; + } } } @@ -1643,22 +1471,27 @@ async function fetchWorktreeCheckoutRefs(options: { cwd: string; localBranchName: string; checkoutRefs: WorktreeCheckoutRef[]; -}): Promise { +}): Promise { let lastResult: | Awaited> | { stderr: string; stdout: string; exitCode: number | null } | null = null; for (const checkoutRef of options.checkoutRefs) { - try { - return await fetchNewLocalBranchAtomically({ + lastResult = await runGitCommand( + [ + "fetch", + checkoutRef.remoteName ?? "origin", + `+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`, + "--force", + ], + { cwd: options.cwd, - localBranchName: options.localBranchName, - remoteName: checkoutRef.remoteName ?? "origin", - remoteRef: checkoutRef.remoteRef, - }); - } catch (error) { - lastResult = - error instanceof Error ? { stderr: error.message, stdout: "", exitCode: 1 } : null; + timeout: 120_000, + acceptExitCodes: [0, 1, 128], + }, + ); + if (lastResult.exitCode === 0) { + return; } } const attemptedRefs = options.checkoutRefs @@ -1669,35 +1502,6 @@ async function fetchWorktreeCheckoutRefs(options: { ); } -async function fetchNewLocalBranchAtomically(options: { - cwd: string; - localBranchName: string; - remoteName: string; - remoteRef: string; -}): Promise { - const temporaryRef = `refs/paseo/worktree-fetch/${randomUUID()}`; - try { - await runGitCommand( - ["fetch", options.remoteName, `${options.remoteRef}:${temporaryRef}`, "--force"], - { cwd: options.cwd, timeout: 120_000 }, - ); - const { stdout } = await runGitCommand(["rev-parse", "--verify", temporaryRef], { - cwd: options.cwd, - }); - const oid = stdout.trim(); - const nullOid = "0".repeat(oid.length); - await runGitCommand(["update-ref", `refs/heads/${options.localBranchName}`, oid, nullOid], { - cwd: options.cwd, - }); - return oid; - } finally { - await runGitCommand(["update-ref", "-d", temporaryRef], { - cwd: options.cwd, - acceptExitCodes: [0, 1, 128], - }); - } -} - async function tryFetchWorktreeTrackingRemote(options: { cwd: string; remoteName: string; diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 90b49b4b13..bdc8a24f23 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -110,46 +110,6 @@ Both fields accept a multiline shell script or an array of commands; commands ru Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to reach files in the original checkout (untracked config, local caches, etc). -## .worktreeinclude - -Use a root-level .worktreeinclude to materialize local source-checkout files before -worktree.setup runs. Each path is relative to the source checkout. - - # Copy is the default. - .env.local - .cache/** - - # Modes can also be explicit. - symlink node_modules - copy .tool-state/** - -Each line is `[copy|symlink] `; the mode is optional and defaults to `copy`. Blank lines -and whole-line comments are ignored. A single star matches within a path segment and a double -star matches recursively; a directory ending in /\*\* materializes that directory as one -recursive entry. Absolute paths, parent-directory paths, and .git paths are rejected. - -Copy entries are independent snapshots: a copied file or directory replaces an existing path on -a later materialization. Symlink entries point directly at -the live source file or directory, so changes through either path affect the same data. Paseo -does not replace an existing file, directory, or different link with a symlink. - -Entries must resolve to regular files or directories. A top-level source symbolic link is -dereferenced only when its canonical target remains inside the source checkout: `copy` snapshots -that target and `symlink` links directly to it. Directory snapshots reject nested symbolic links -so Paseo never writes through an unexpected path. A symlinked directory intentionally exposes its -live source contents. - -Prefer paths ignored by the target branch. For a symlinked directory, use an ignore rule without -a trailing slash (for example, node_modules, not node_modules/), because Git treats the link -itself as a file. Unignored materialized paths appear in git status. - -On Windows, Paseo uses junctions for local directories. File links and network-directory links -require Windows symbolic-link support. It never silently copies an explicit `symlink ` -entry; enable Developer Mode or switch that entry to `copy ` if link creation fails. - -Archiving removes only the worktree's links, not their source targets. If the source path is -later moved or deleted, a symlink becomes broken; Paseo does not repair it automatically. - ## Scripts and services `scripts` are named commands you can run inside a worktree on demand. Mark one as a _service_ and Paseo supervises it as a long-running process, assigns it a port, and routes HTTP traffic to it through the daemon's reverse proxy. From 8f8a373eeadf474ed7a80a3bc06dc5eddfdf6fb7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Jul 2026 01:45:18 +0200 Subject: [PATCH 09/46] docs(changelog): 0.2.4 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f70b7041..19885d3963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 0.2.4 - 2026-07-30 + +### Added + +- Set an agent's thinking level from the CLI ([#2533](https://github.com/getpaseo/paseo/pull/2533)) +- Switch projects while creating a workspace with ⌘P / Ctrl+P ([#2110](https://github.com/getpaseo/paseo/pull/2110) by [@turtleDev](https://github.com/turtleDev)) +- Open a project or workspace folder from the sidebar ([#2491](https://github.com/getpaseo/paseo/pull/2491) by [@PTK030](https://github.com/PTK030)) +- Flick up on the chat to dismiss the keyboard ([#2417](https://github.com/getpaseo/paseo/pull/2417) by [@nllptrx](https://github.com/nllptrx)) +- Custom providers can read the agent's working directory ([#2563](https://github.com/getpaseo/paseo/pull/2563)) + +### Fixed + +- The same repository added on more than one machine appears as one project in the sidebar ([#2565](https://github.com/getpaseo/paseo/pull/2565)) +- Idle agents keep their background work instead of shutting down when unused ([#2590](https://github.com/getpaseo/paseo/pull/2590)) +- Desktop no longer crashes at startup while restoring your file tree ([#2595](https://github.com/getpaseo/paseo/pull/2595)) +- Plan approval only shows the latest proposal ([#2534](https://github.com/getpaseo/paseo/pull/2534)) +- Opus 5 appears once in the model list, with its full 1M context ([#2497](https://github.com/getpaseo/paseo/pull/2497)) +- The context meter no longer blanks out partway through a conversation ([#2494](https://github.com/getpaseo/paseo/pull/2494) by [@theslava](https://github.com/theslava)) +- A very large working diff no longer drops your session ([#2488](https://github.com/getpaseo/paseo/pull/2488) by [@nikuscs](https://github.com/nikuscs)) +- Interrupting Pi no longer surfaces a stream error ([#2311](https://github.com/getpaseo/paseo/pull/2311) by [@mcowger](https://github.com/mcowger)) +- Grok usage shows again in Settings ([#2353](https://github.com/getpaseo/paseo/pull/2353) by [@jasonhnd](https://github.com/jasonhnd)) +- OMP models that report no context window now load ([#2406](https://github.com/getpaseo/paseo/pull/2406) by [@astartsky](https://github.com/astartsky)) +- Paseo's own tools are available directly in OMP ([#2418](https://github.com/getpaseo/paseo/pull/2418) by [@perezd](https://github.com/perezd)) +- Codex finds the skills defined in your project ([#2423](https://github.com/getpaseo/paseo/pull/2423) by [@dwyanewang](https://github.com/dwyanewang)) +- Pull request comments containing HTML render correctly ([#2432](https://github.com/getpaseo/paseo/pull/2432) by [@mcowger](https://github.com/mcowger)) +- Self-hosted forge links keep their port ([#2478](https://github.com/getpaseo/paseo/pull/2478) by [@muzhi1991](https://github.com/muzhi1991)) +- The Linux AppImage launches when opened from your desktop ([#2439](https://github.com/getpaseo/paseo/pull/2439) by [@stonegray](https://github.com/stonegray)) +- Repository search works with older versions of the GitHub CLI ([#2611](https://github.com/getpaseo/paseo/pull/2611)) + ## 0.2.3 - 2026-07-27 ### Added From 2f913736fa37f3bd8f755a0ea238031ccadcb0f1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Jul 2026 01:48:57 +0200 Subject: [PATCH 10/46] chore(release): cut 0.2.4 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6f56e046bf..52323fddad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.2.3", + "version": "0.2.4", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35211,7 +35211,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36236,12 +36236,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.3", - "@getpaseo/protocol": "0.2.3", - "@getpaseo/server": "0.2.3", + "@getpaseo/client": "0.2.4", + "@getpaseo/protocol": "0.2.4", + "@getpaseo/server": "0.2.4", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36488,10 +36488,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { - "@getpaseo/protocol": "0.2.3", - "@getpaseo/relay": "0.2.3", + "@getpaseo/protocol": "0.2.4", + "@getpaseo/relay": "0.2.4", "zod": "^4.4.3" }, "devDependencies": { @@ -36502,7 +36502,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.2.3", + "version": "0.2.4", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36745,7 +36745,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37641,7 +37641,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -37873,7 +37873,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "zod": "^4.4.3" }, @@ -37886,7 +37886,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38104,15 +38104,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.3", - "@getpaseo/highlight": "0.2.3", - "@getpaseo/protocol": "0.2.3", - "@getpaseo/relay": "0.2.3", + "@getpaseo/client": "0.2.4", + "@getpaseo/highlight": "0.2.4", + "@getpaseo/protocol": "0.2.4", + "@getpaseo/relay": "0.2.4", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38649,7 +38649,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index ec45bed387..bbc4d72dbf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.2.3", + "version": "0.2.4", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 3936b7db52..554dba61c8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.2.3", + "version": "0.2.4", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 01e3c7238c..a838626a8d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.2.3", + "version": "0.2.4", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.3", - "@getpaseo/protocol": "0.2.3", - "@getpaseo/server": "0.2.3", + "@getpaseo/client": "0.2.4", + "@getpaseo/protocol": "0.2.4", + "@getpaseo/server": "0.2.4", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index d6993a4962..93684f6574 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.2.3", + "version": "0.2.4", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.2.3", - "@getpaseo/relay": "0.2.3", + "@getpaseo/protocol": "0.2.4", + "@getpaseo/relay": "0.2.4", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index c6e97aa100..c7e00a505e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.2.3", + "version": "0.2.4", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 3db50c0180..767ac7c5df 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.3", + "version": "0.2.4", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 3b0aa34786..2802ee7e00 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.2.3", + "version": "0.2.4", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 9455c455d3..c674e56ada 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.2.3", + "version": "0.2.4", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index 86cd6a05a3..d56d4975f3 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.2.3", + "version": "0.2.4", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 577cb449cd..31130ea8ec 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.2.3", + "version": "0.2.4", "description": "Paseo backend server", "files": [ "dist/server", @@ -67,10 +67,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.3", - "@getpaseo/highlight": "0.2.3", - "@getpaseo/protocol": "0.2.3", - "@getpaseo/relay": "0.2.3", + "@getpaseo/client": "0.2.4", + "@getpaseo/highlight": "0.2.4", + "@getpaseo/protocol": "0.2.4", + "@getpaseo/relay": "0.2.4", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 2a73159511..64890e522a 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.2.3", + "version": "0.2.4", "private": true, "type": "module", "scripts": { From d1ce2b77f7adb13a7f9f3d1b778c9a2efdadf460 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:58:09 +0000 Subject: [PATCH 11/46] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 73a04d1862..4e91ef1d05 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc= +sha256-KKDRzewqiwmsvF/7qFEJ3DK9DXtpAPUPxt0k9wkr1SY= From b345e53a9b7a6ea15c9edb4f94da30def9660f24 Mon Sep 17 00:00:00 2001 From: Cafe Babe <41276330+Neumannzc@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:32:44 +0800 Subject: [PATCH 12/46] Fix Linux desktop package startup (#2654) Restore the Linux startup behavior that shipped in 0.2.3 by reverting the generalized sandbox detection from #2439. Co-authored-by: tang --- packages/desktop/src/main.ts | 16 ++-- .../desktop/src/system/linux-sandbox.test.ts | 82 ------------------- packages/desktop/src/system/linux-sandbox.ts | 45 ---------- 3 files changed, 7 insertions(+), 136 deletions(-) delete mode 100644 packages/desktop/src/system/linux-sandbox.test.ts delete mode 100644 packages/desktop/src/system/linux-sandbox.ts diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index b066e50b71..5509fdd0e7 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -8,7 +8,7 @@ import { inheritLoginShellEnv } from "./login-shell-env.js"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { existsSync, statSync } from "node:fs"; +import { existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { app, @@ -40,7 +40,6 @@ import { buildStandardContextMenuItems, } from "./window/window-manager.js"; import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js"; -import { configureLinuxSandbox } from "./system/linux-sandbox.js"; import { registerDialogHandlers } from "./features/dialogs.js"; import { registerNotificationHandlers, @@ -310,13 +309,12 @@ if (forcedUserDataDir) { } } -configureLinuxSandbox({ - platform: process.platform, - resourcesPath: process.resourcesPath, - statSandbox: (sandboxPath) => statSync(sandboxPath), - disableSandbox: () => app.commandLine.appendSwitch("no-sandbox"), - reportInspectionError: (error) => log.error("[linux-sandbox] failed to inspect helper", error), -}); +// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID +// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the +// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code. +if (process.platform === "linux" && process.env.APPIMAGE) { + app.commandLine.appendSwitch("no-sandbox"); +} // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging // rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). diff --git a/packages/desktop/src/system/linux-sandbox.test.ts b/packages/desktop/src/system/linux-sandbox.test.ts deleted file mode 100644 index 988ac57451..0000000000 --- a/packages/desktop/src/system/linux-sandbox.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { configureLinuxSandbox } from "./linux-sandbox"; - -interface SandboxMetadata { - mode: number; - uid: number; -} - -function configureWithSandbox( - sandbox: SandboxMetadata | Error, - platform: NodeJS.Platform = "linux", -) { - const disabledSwitches: string[] = []; - const inspectionErrors: unknown[] = []; - - configureLinuxSandbox({ - platform, - resourcesPath: "/opt/Paseo/resources", - statSandbox: () => { - if (sandbox instanceof Error) { - throw sandbox; - } - return sandbox; - }, - disableSandbox: () => disabledSwitches.push("no-sandbox"), - reportInspectionError: (error) => inspectionErrors.push(error), - }); - - return { disabledSwitches, inspectionErrors }; -} - -function createFileSystemError(code: string): NodeJS.ErrnoException { - const error: NodeJS.ErrnoException = new Error(code); - error.code = code; - return error; -} - -describe("configureLinuxSandbox", () => { - it("disables the sandbox when an AppImage mount strips SUID", () => { - expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({ - disabledSwitches: ["no-sandbox"], - inspectionErrors: [], - }); - }); - - it("keeps the sandbox for a root-owned 4755 helper", () => { - expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({ - disabledSwitches: [], - inspectionErrors: [], - }); - }); - - it("disables the sandbox when a SUID helper is not root-owned", () => { - expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({ - disabledSwitches: ["no-sandbox"], - inspectionErrors: [], - }); - }); - - it("disables the sandbox when the helper is missing", () => { - expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({ - disabledSwitches: ["no-sandbox"], - inspectionErrors: [], - }); - }); - - it("keeps the sandbox and reports unexpected inspection failures", () => { - const permissionError = createFileSystemError("EACCES"); - - expect(configureWithSandbox(permissionError)).toEqual({ - disabledSwitches: [], - inspectionErrors: [permissionError], - }); - }); - - it("does not inspect or configure the sandbox outside Linux", () => { - expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({ - disabledSwitches: [], - inspectionErrors: [], - }); - }); -}); diff --git a/packages/desktop/src/system/linux-sandbox.ts b/packages/desktop/src/system/linux-sandbox.ts deleted file mode 100644 index 724195d4ef..0000000000 --- a/packages/desktop/src/system/linux-sandbox.ts +++ /dev/null @@ -1,45 +0,0 @@ -import path from "node:path"; - -const REQUIRED_SANDBOX_MODE = 0o4755; -const PERMISSION_BITS = 0o7777; - -interface SandboxMetadata { - mode: number; - uid: number; -} - -interface LinuxSandboxConfiguration { - platform: NodeJS.Platform; - resourcesPath: string; - statSandbox: (sandboxPath: string) => SandboxMetadata; - disableSandbox: () => void; - reportInspectionError: (error: unknown) => void; -} - -function isMissingSandbox(error: unknown): boolean { - return error instanceof Error && "code" in error && error.code === "ENOENT"; -} - -export function configureLinuxSandbox(input: LinuxSandboxConfiguration): void { - if (input.platform !== "linux") { - return; - } - - try { - const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox"); - const sandbox = input.statSandbox(sandboxPath); - const hasUsableSandbox = - sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE; - - if (!hasUsableSandbox) { - input.disableSandbox(); - } - } catch (error) { - if (isMissingSandbox(error)) { - input.disableSandbox(); - return; - } - - input.reportInspectionError(error); - } -} From 4d7d6a7d1b07d82da45c5059ba1a36343ea34286 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Jul 2026 12:33:47 +0200 Subject: [PATCH 13/46] docs(changelog): 0.2.5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19885d3963..070fa7cf58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.2.5 - 2026-07-30 + +### Fixed + +- The Linux desktop app launches correctly again after `.deb` installation ([#2654](https://github.com/getpaseo/paseo/pull/2654) by [@Neumannzc](https://github.com/Neumannzc)) + ## 0.2.4 - 2026-07-30 ### Added From 95257a6353824140199d5667e59f57c3b18bc7df Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Jul 2026 13:17:51 +0200 Subject: [PATCH 14/46] docs(changelog): simplify 0.2.5 entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 070fa7cf58..7d5d81aed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- The Linux desktop app launches correctly again after `.deb` installation ([#2654](https://github.com/getpaseo/paseo/pull/2654) by [@Neumannzc](https://github.com/Neumannzc)) +- Fixed the Linux Debian package ([#2654](https://github.com/getpaseo/paseo/pull/2654) by [@Neumannzc](https://github.com/Neumannzc)) ## 0.2.4 - 2026-07-30 From 6fc491e6220fba6543bbbe4bf1b1f58cfe59228b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Jul 2026 13:22:40 +0200 Subject: [PATCH 15/46] chore(release): cut 0.2.5 --- package-lock.json | 42 ++++++++++++------------ package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 8 ++--- packages/client/package.json | 6 ++-- packages/desktop/package.json | 2 +- packages/expo-two-way-audio/package.json | 2 +- packages/highlight/package.json | 2 +- packages/protocol/package.json | 2 +- packages/relay/package.json | 2 +- packages/server/package.json | 10 +++--- packages/website/package.json | 2 +- 12 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 52323fddad..0f8d8bc744 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.2.4", + "version": "0.2.5", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -35211,7 +35211,7 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@codemirror/commands": "6.10.4", "@codemirror/language": "6.12.4", @@ -36236,12 +36236,12 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.4", - "@getpaseo/protocol": "0.2.4", - "@getpaseo/server": "0.2.4", + "@getpaseo/client": "0.2.5", + "@getpaseo/protocol": "0.2.5", + "@getpaseo/server": "0.2.5", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -36488,10 +36488,10 @@ }, "packages/client": { "name": "@getpaseo/client", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { - "@getpaseo/protocol": "0.2.4", - "@getpaseo/relay": "0.2.4", + "@getpaseo/protocol": "0.2.5", + "@getpaseo/relay": "0.2.5", "zod": "^4.4.3" }, "devDependencies": { @@ -36502,7 +36502,7 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.2.4", + "version": "0.2.5", "license": "AGPL-3.0-or-later", "dependencies": { "@getpaseo/cli": "*", @@ -36745,7 +36745,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "devDependencies": { "@types/jest": "^29.5.14", @@ -37641,7 +37641,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@codemirror/language": "6.12.4", "@codemirror/legacy-modes": "^6.5.3", @@ -37873,7 +37873,7 @@ }, "packages/protocol": { "name": "@getpaseo/protocol", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "zod": "^4.4.3" }, @@ -37886,7 +37886,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -38104,15 +38104,15 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.4", - "@getpaseo/highlight": "0.2.4", - "@getpaseo/protocol": "0.2.4", - "@getpaseo/relay": "0.2.4", + "@getpaseo/client": "0.2.5", + "@getpaseo/highlight": "0.2.5", + "@getpaseo/protocol": "0.2.5", + "@getpaseo/relay": "0.2.5", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38649,7 +38649,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", "@cloudflare/workers-types": "^4.20260317.1", diff --git a/package.json b/package.json index bbc4d72dbf..0c697550c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.2.4", + "version": "0.2.5", "private": true, "description": "Paseo: voice-controlled development environment for local AI coding agents", "keywords": [ diff --git a/packages/app/package.json b/packages/app/package.json index 554dba61c8..ea98c21482 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/app", - "version": "0.2.4", + "version": "0.2.5", "private": true, "main": "index.ts", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index a838626a8d..bbba6ea578 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.2.4", + "version": "0.2.5", "description": "Paseo CLI - control your AI coding agents from the command line", "bin": { "paseo": "bin/paseo" @@ -28,9 +28,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.2.4", - "@getpaseo/protocol": "0.2.4", - "@getpaseo/server": "0.2.4", + "@getpaseo/client": "0.2.5", + "@getpaseo/protocol": "0.2.5", + "@getpaseo/server": "0.2.5", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index 93684f6574..0c6e7ffa99 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.2.4", + "version": "0.2.5", "description": "Paseo client SDK package", "files": [ "dist", @@ -35,8 +35,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.2.4", - "@getpaseo/relay": "0.2.4", + "@getpaseo/protocol": "0.2.5", + "@getpaseo/relay": "0.2.5", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index c7e00a505e..083370fe24 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.2.4", + "version": "0.2.5", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 767ac7c5df..5603116a63 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.2.4", + "version": "0.2.5", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 2802ee7e00..73daf0b773 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.2.4", + "version": "0.2.5", "files": [ "dist", "!dist/**/*.map" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index c674e56ada..3a9b5d89a3 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.2.4", + "version": "0.2.5", "description": "Paseo shared protocol schemas and wire types", "files": [ "dist", diff --git a/packages/relay/package.json b/packages/relay/package.json index d56d4975f3..178a030f40 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.2.4", + "version": "0.2.5", "description": "Paseo relay for bridging daemon and client connections", "files": [ "dist", diff --git a/packages/server/package.json b/packages/server/package.json index 31130ea8ec..382d8c589e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.2.4", + "version": "0.2.5", "description": "Paseo backend server", "files": [ "dist/server", @@ -67,10 +67,10 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "^0.3.220", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.2.4", - "@getpaseo/highlight": "0.2.4", - "@getpaseo/protocol": "0.2.4", - "@getpaseo/relay": "0.2.4", + "@getpaseo/client": "0.2.5", + "@getpaseo/highlight": "0.2.5", + "@getpaseo/protocol": "0.2.5", + "@getpaseo/relay": "0.2.5", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/website/package.json b/packages/website/package.json index 64890e522a..f57e690976 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.2.4", + "version": "0.2.5", "private": true, "type": "module", "scripts": { From 4e6f759da9d0e4277623087758fedb0c49a06560 Mon Sep 17 00:00:00 2001 From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:31:41 +0000 Subject: [PATCH 16/46] fix: update lockfile signatures and Nix hash [skip ci] --- nix/npm-deps.hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 4e91ef1d05..7cd36b4f08 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-KKDRzewqiwmsvF/7qFEJ3DK9DXtpAPUPxt0k9wkr1SY= +sha256-FbAuGkXHC6uCLED4X6vOW/T5eUrdxAxNZME6gWsc0w0= From 588fdc794bfc20014fa090e74d605266b0ad1a92 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 30 Jul 2026 15:52:50 +0200 Subject: [PATCH 17/46] docs: rewrite contributing guide, add QA and compatibility docs Issues are bug-only now. The feature-request template is gone, requests opened as issues get closed automatically, and feature talk moves to product discussions on GitHub Discussions and Discord. "Product discussion" is the term everywhere, recorded in the glossary. Contributing states the QA evidence bar and links out instead of teaching; docs/qa.md carries the depth. The protocol back-compat rules moved out of CLAUDE.md into docs/protocol-compatibility.md, since that constraint is invisible in development where both sides always run the same version. CLAUDE.md gains doc-writing and voice rules to keep docs from accumulating appended paragraphs. design.md gains an alignment section (glyph rails) and a layout-shift rule. --- .github/ISSUE_TEMPLATE/bug-report.yml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 7 +- .github/ISSUE_TEMPLATE/feature-request.yml | 43 ------ CLAUDE.md | 52 ++++--- CONTRIBUTING.md | 156 ++++++++++++++++----- docs/design.md | 30 +++- docs/glossary.md | 1 + docs/protocol-compatibility.md | 59 ++++++++ docs/qa.md | 77 ++++++++++ 9 files changed, 323 insertions(+), 104 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 docs/protocol-compatibility.md create mode 100644 docs/qa.md diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 195651822c..c793b23cfa 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - I'm a solo maintainer and don't always keep up with GitHub Issues daily. If something is urgent or blocking you, [Discord](https://discord.gg/jz8T2uahpH) is the fastest place to reach me. + Issues are only for bugs. Product discussions go in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH) — feature requests opened as issues get closed automatically. Before opening, please: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index de533faa4d..027b42a33d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: - - name: Discord + - name: Product discussion on Discord url: https://discord.gg/jz8T2uahpH - about: Urgent or blocking issues, quick questions, sharing a video of a bug, or anything that's better as a chat. + about: Any discussion, product or otherwise. Product discussions go in #product — describe what you're trying to do, how you do it today, and where Paseo gets in the way. + - name: Product discussion on GitHub + url: https://github.com/getpaseo/paseo/discussions + about: Same conversation, on GitHub. Issues are only for bugs, and feature requests opened as issues get closed automatically. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml deleted file mode 100644 index cc08d17bcb..0000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Feature request -description: Propose a new feature or a change to existing behavior. -title: "feat: " -labels: ["enhancement"] -body: - - type: markdown - attributes: - value: | - Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain. - - Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code. - - - type: checkboxes - id: prior-search - attributes: - label: Prior search - options: - - label: I searched existing issues and discussions, and this isn't already proposed. - required: true - - - type: textarea - id: problem - attributes: - label: What's the problem - description: What are you actually trying to do, and why is the current behavior in the way? - placeholder: | - When I'm doing X, I want to Y, but Paseo currently Z. - validations: - required: true - - - type: textarea - id: proposal - attributes: - label: What would solve it - description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals. - validations: - required: true - - - type: textarea - id: alternatives - attributes: - label: Alternatives you considered - description: Optional. What else did you try, and why doesn't it work? diff --git a/CLAUDE.md b/CLAUDE.md index a763b616ff..4ae9ebe89f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ This is an npm workspace monorepo: `docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources. -At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`. +At the start of non-trivial work, list `docs/` and skim anything relevant to the task. | Doc | What's in it | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | @@ -29,7 +29,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations | | [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms | | [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization | -| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons | +| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden | | [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example | | [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it | | [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | @@ -42,9 +42,11 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config | | [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | | [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs | +| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging | | [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | | [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | +| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof | | [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | | [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints | | [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | @@ -55,6 +57,30 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider | | [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | +### Writing docs + +- **Integrate, don't append.** Find the doc that owns the subject and rewrite the part that is now wrong. The standard failure is finishing a task and adding a paragraph to the bottom of the closest-looking doc; ten tasks later the doc is a pile of paragraphs in discovery order. `docs/custom-providers.md` is what that looks like. +- **Don't document logic.** Prose that restates code drifts from the code and loses. Write down what the code can't tell you: why something is shaped the way it is, the gotcha that cost an afternoon, conventions nothing enforces, constraints that span packages or versions. If a reader could get it in two minutes by opening the file, cut it. +- **One fact, one doc.** Every other mention is a link. If you are about to write the same paragraph in two docs, one of them is a link. +- **Respect the layers.** `CONTRIBUTING.md` and this file name things and link out. Activity docs like `docs/qa.md` and `docs/testing.md` set the bar for a kind of work. Subject docs like `docs/unistyles.md` own one thing completely. A layer never re-explains the one below it. +- **One subject per doc.** If the subject doesn't fit in a sentence, split the doc. A section per provider, vendor, or platform is a table plus one worked example. +- **Delete.** Obsolete sections go. Prefer a `packages/app/src/thing.ts:120` reference over a pasted block. +- **New doc?** Add a row to the table above and link it from the docs that should send readers there. +- Code-level facts belong in comments next to the code, not here. + +### Doc voice + +Plain and short. Second person. State the rule, then the reason when the reason isn't obvious. Match the doc you're editing. + +Do not: + +- Write a sentence to land a point. "It's not X, it's Y", "That's not a Z, that's a W", and every other setup-and-punchline shape. +- Add a clause that only asserts importance: "and that matters", "which is what keeps it working", "this is critical". +- Use "honest", "robust", "seamless", "powerful", "simply", "just", "delightful". +- Restate something you already said, in different words, for emphasis. +- Hedge with "generally", "typically", or "you may want to" when the answer is "do this". +- Clear your throat: "It's worth noting that", "In order to", "This section covers". + ## Quick start ```bash @@ -94,23 +120,11 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir - **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script: - `npm run lint -- packages/app/src/components/message.tsx` - `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx` -- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts: - - **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client. - - New fields: `.optional()` with a sensible default. - - Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null). - - Removed fields stay accepted (we stop sending them, not stop reading them). - - Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?" - - Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass. - - Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled. - - `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers. - - **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it. - - **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature. - - **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape. - - **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site. - - Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal. - - **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones. - -- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable. +- **The protocol stays backward-compatible. Features don't have to.** Read [docs/protocol-compatibility.md](docs/protocol-compatibility.md) before touching `packages/protocol`. The short version: + - **Protocol contract (always):** an old client parses messages from a new daemon, and a new daemon parses messages from an old client. New fields are optional; never narrow, never remove, never require. Wire schemas stay pure — no `.transform()`, `.catch()`, or `.preprocess()`. + - **Feature contract (per-feature):** gate the capability once on `server_info.features.*`, then run the feature or tell the user to update the host. No fallback paths, no defensive branches. + - **Every shim is tagged.** `// COMPAT(name): added in vX, remove after ` at the site that has to be deleted. `rg "COMPAT\("` is the cleanup backlog; untagged back-compat is permanent by accident. + - **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones. ## Platform gating diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index feb2027c2d..c499050054 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,56 +1,148 @@ # Contributing to Paseo -Paseo is an opinionated product maintained by one person right now. +Thank you for taking the time to contribute to Paseo. -The product covers a lot of surface: mobile, desktop, web, the daemon, the relay, and both self-hosted and hosted setups. +## Philosophy -Contributing takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer. +Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md). -## Becoming a maintainer +Given Paseo's scope, contributing to to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer. -There's no formal process to become a maintainer, if you consistently contribute and help out, you'll become one. +I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it. -Here's the progression: +## Submit bugs in GitHub issues or Discord -1. Get involved in the community: answer questions in Discord and on GitHub -2. Triage bugs: replicate and help fix them -3. Work on maintainer-approved features +Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH). -The reason for this progression is so that you can gain all the context you need to take on more responsibility, so that I can see if you have what it takes to be a maintainer. +> [!IMPORTANT] +> Feature requests opened as issues will get closed automatically -Learning on the job is fine, I do not care how many years of experience you have, what I care about is that you get the vision and want to contribute. +Include: + +- What you did, what you expected, what happened instead +- Steps to reproduce +- Paseo version, OS, and agent provider +- Logs, command output, screenshots. A video if it's about interaction, animation, or timing +- Redact secrets, keep the technical details + +Try the latest release first and search for the same symptom. You can submit a pull request with the fix, it's not required. + +If you used an agent to investigate, submit the raw evidence like logs, commands, and repro steps it collected. + +Do not just submit your agent's findings, those usually don't have enough information, and the diagnosis is likely to be incorrect. + +## Start a product discussion in GitHub Discussions or Discord + +There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow: + +- What are you trying to do? +- How do you do it today? +- Where does Paseo get in the way? +- What would the flow look like if it worked for you? + +"Can you build X" is not useful because it doesn't tell me about your problem. + +A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build. + +Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will definitely make it's way to me. ## Pull requests -✅ Will be accepted +Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed. -- Keep it to one focused change -- Link to an issue -- Explain the problem you're solving -- Include repro steps if it's a bug -- Include QA/testing evidence -- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web -- If you only tested one platform, say that clearly +Here is the criteria I use to decide: + +✅ Likely to be accepted + +- Fits the product vision +- One focused change +- Links the bug it fixes, or the discussion behind it if there is one +- Addresses bot reviews +- Explains the problem you're solving +- QA evidence +- Automated tests +- Screenshots or video for UI changes, on every affected platform +- Says which platforms you tested and which you didn't +- Maintainer edits enabled ⛔️ Will be rejected -- Bundle unrelated changes -- Fail basic checks like typecheck, formatting or linting -- Add a feature or design change that wasn't discussed first -- Submit no evidence of testing -- Skip the linked issue +- Bundles unrelated changes +- Fails checks +- Ignores bot reviews +- Takes a feature or the design in a direction I don't want +- No QA evidence +- No tests - Clearly fully AI-generated PR -## Requesting features +### What to expect -If you need a feature implemented, create a Github issue or a thread in Discord. +- PRs that were explicitly approved in a discussion are preferred. +- Unsolicited PR can be closed without a detailed review. +- Your PR can be narrowed, refactored, or redesigned. +- Your PR might be accpeted but not merged immediately. +- You wil be attributed for your work in the changelog, even if I redesign your PR -Explain the problem you want to solve: your use case, where Paseo falls short today, and the flow you expect. +## QA evidence -## AI assistance +QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar. -Using AI to help write code is fine, but you must: +Pull requests without evidence will be closed. -- Ensure your agents read the docs -- Understand the code you submit -- Review and test the code yourself +What's accepted: + +- The shell commands you ran, pasted with their output +- The tests you added and their results +- Before and after screenshots +- A video of the whole interaction +- Logs, requests, responses + +Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static. + +The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it. + +## I want to become a maintainer + +There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement. + +## FAQ + +### I'm blocked, where do I go? + +Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there. + +### Do you prefer Github or Discord? + +I am more active on Discord for general questions. + +I prefer Github for bugs because the template helps collect information more efficiently. + +### Why was my feature request closed? + +Issues are only for bugs. Open it as a product discussion instead, framed as a workflow. + +### Why didn't my PR get a detailed review? + +Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume. + +### How long until someone looks at my pull request? + +It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask. + +### Why do you want tests and a video for a small change? + +Because it's faster and safer than me checking out and running every PR myself to test it manually. + +QA evidence help me take your PR seriously for a deeper review and maintainer QA. + +### I only have one platform, can I still contribute? + +Yes. Test what you have, and say what you didn't test. + +### Can I use an agent to write my contribution? + +Yes, as long as you understand what you're submitting and you tested it yourself. + +### Is there a roadmap I can read? + +[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next. diff --git a/docs/design.md b/docs/design.md index 790465389f..0289894264 100644 --- a/docs/design.md +++ b/docs/design.md @@ -122,7 +122,21 @@ The whitespace is the design. --- -## 8. Responsiveness +## 8. Alignment + +Things align to their glyphs, not to their boxes. A row's leading icon, its title, and the label of the button in its trailing slot sit on the same rails — the ink lines up, not the padding, not the touch target, not the hover background. + +Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset. + +The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. + +Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical. + +One row off the rail makes the whole card look unconsidered. + +--- + +## 9. Responsiveness Compact-first. The small case is designed; the large case adds chrome around it. @@ -143,7 +157,7 @@ A new list+detail feature copies the settings shell. A new workspace-shaped feat --- -## 9. Copy and voice +## 10. Copy and voice Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong. @@ -165,7 +179,7 @@ Terminology: --- -## 10. States +## 11. States Loading is inline by default. `` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use ``; the menu item handles its own pending state. @@ -185,9 +199,11 @@ Partial failure (a list mostly fine but one source errored) is a bordered banner State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`. +Changing state must not move the layout. A row that grows when its badge arrives, a card that reflows when a count resolves, a list that jumps as data streams in — all wrong. Reserve the space the loaded state will need, so the skeleton, the spinner, and the content occupy the same box. A surface that shifts under the user stops feeling calm. + --- -## 11. List rows +## 12. List rows The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`). @@ -203,7 +219,7 @@ Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as th --- -## 12. Status pills and badges +## 13. Status pills and badges Status pills are `palette.[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical. @@ -213,7 +229,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, ` --- -## 13. Forbidden +## 14. Forbidden - `fontWeight.medium` on row titles, body text, button labels, badge text, or `` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `` is responsive `400/300` and is never overridden. - `` wrapping `` to make a button. ` - - + + + + {title} + {description ? {description} : null} + + {state.kind !== "deleted" ? ( + + {state.kind === "changed" && state.canOverwrite ? ( + + ) : null} + {state.kind === "changed" ? ( + + ) : null} + {state.kind === "checkFailed" ? ( + + ) : null} + + ) : null} + ); } + +const styles = StyleSheet.create((theme) => ({ + container: { + minHeight: 48, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderBottomWidth: 1, + borderBottomColor: theme.colors.palette.amber[500], + backgroundColor: theme.colors.surface1, + }, + message: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], + }, + title: { + color: theme.colors.palette.amber[500], + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + actions: { + flexShrink: 0, + flexDirection: "row", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/file-pane/editor/model.test.ts b/packages/app/src/file-pane/editor/model.test.ts index 026e8bc445..966537c857 100644 --- a/packages/app/src/file-pane/editor/model.test.ts +++ b/packages/app/src/file-pane/editor/model.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; import { FileEditorModel, + getFileConflictCallout, type FileEditorClock, type FileEditorFile, type FileEditorSession, @@ -335,6 +336,45 @@ describe("FileEditorModel", () => { expect(session.writes).toEqual([]); }); + test("clears a transient check error when the recovered file is unchanged", () => { + const { model } = makeModel(); + model.receiveFileVersion({ + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "Requested path is not a file", + }); + + model.receiveFileVersion(ready()); + + expect(model.getSnapshot()).toMatchObject({ + status: "clean", + modified: false, + observedVersion: { status: "ready" }, + error: null, + }); + }); + + test("resumes autosave when a dirty file recovers unchanged from a check error", async () => { + const { model, session, clock } = makeModel(); + model.edit("local"); + model.receiveFileVersion({ + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "Requested path is not a file", + }); + + model.receiveFileVersion(ready()); + clock.fire(); + await Promise.resolve(); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", modified: false }); + expect(session.writes).toEqual([ + { content: "local", expectedModifiedAt: "2026-07-18T00:00:00.000Z" }, + ]); + }); + test("dispose cancels pending autosave", () => { const { model, session, clock } = makeModel(); model.edit("local"); @@ -358,4 +398,38 @@ describe("FileEditorModel", () => { await Promise.resolve(); expect(session.writes).toHaveLength(1); }); + + test("maps conflict versions to one exhaustive callout state", () => { + const { model } = makeModel(); + const snapshot = model.getSnapshot(); + + expect(getFileConflictCallout(snapshot)).toBeNull(); + expect( + getFileConflictCallout({ + ...snapshot, + status: "conflict", + modified: true, + observedVersion: ready("2026-07-18T00:00:01.000Z"), + }), + ).toEqual({ kind: "changed", canOverwrite: true }); + expect( + getFileConflictCallout({ + ...snapshot, + status: "conflict", + observedVersion: { status: "missing", cwd: "/workspace", path: "file.ts" }, + }), + ).toEqual({ kind: "deleted" }); + expect( + getFileConflictCallout({ + ...snapshot, + status: "conflict", + observedVersion: { + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "unreadable", + }, + }), + ).toEqual({ kind: "checkFailed" }); + }); }); diff --git a/packages/app/src/file-pane/editor/model.ts b/packages/app/src/file-pane/editor/model.ts index 48cc77e954..79b736899b 100644 --- a/packages/app/src/file-pane/editor/model.ts +++ b/packages/app/src/file-pane/editor/model.ts @@ -13,6 +13,11 @@ export interface FileEditorSnapshot { error: string | null; } +export type FileConflictCallout = + | { kind: "changed"; canOverwrite: boolean } + | { kind: "deleted" } + | { kind: "checkFailed" }; + export interface FileEditorFile { content: string; hasBom: boolean; @@ -28,6 +33,11 @@ export interface FileEditorSession { }): Promise; } +export interface FileVersionSource { + subscribe(listener: () => void): () => void; + getVersion(): FileVersion | null; +} + export interface FileEditorClock { setTimeout(callback: () => void, delayMs: number): ReturnType; clearTimeout(handle: ReturnType): void; @@ -53,6 +63,7 @@ export class FileEditorModel { private observedWhileSaving: FileVersion | null = null; private persistedContent: string; private hasBom: boolean; + private unsubscribeVersionSource: (() => void) | null = null; constructor(input: { file: FileEditorFile; @@ -81,6 +92,21 @@ export class FileEditorModel { getSnapshot = (): FileEditorSnapshot => this.snapshot; + connectFileVersions(source: FileVersionSource): void { + this.disconnectFileVersions(); + const receiveVersion = () => { + const version = source.getVersion(); + if (version) this.receiveFileVersion(version); + }; + this.unsubscribeVersionSource = source.subscribe(receiveVersion); + receiveVersion(); + } + + disconnectFileVersions(): void { + this.unsubscribeVersionSource?.(); + this.unsubscribeVersionSource = null; + } + edit(content: string): void { if (this.disposed || content === this.snapshot.content) return; const modified = content !== this.persistedContent; @@ -124,6 +150,7 @@ export class FileEditorModel { } return; } + if (this.restoreUnchangedConflict(version)) return; this.setSnapshot({ ...this.snapshot, observedVersion: version }); if (this.snapshot.status === "saving") { this.observedWhileSaving = version; @@ -151,6 +178,7 @@ export class FileEditorModel { this.disposed = true; this.saveSequence += 1; this.clearAutosave(); + this.disconnectFileVersions(); this.listeners.clear(); } @@ -279,6 +307,29 @@ export class FileEditorModel { }); } + private restoreUnchangedConflict(version: FileVersion): boolean { + if ( + this.snapshot.status !== "conflict" || + version.status !== "ready" || + this.snapshot.version.status !== "ready" || + !sameVersion(version, this.snapshot.version) + ) { + return false; + } + const modified = this.snapshot.content !== this.persistedContent; + this.setSnapshot({ + ...this.snapshot, + status: modified ? "dirty" : "clean", + modified, + version, + observedVersion: version, + error: null, + }); + if (modified) this.scheduleAutosave(); + else this.clearAutosave(); + return true; + } + private scheduleAutosave(): void { this.clearAutosave(); this.autosave = this.clock.setTimeout(() => { @@ -299,6 +350,24 @@ export class FileEditorModel { } } +export function getFileConflictCallout(snapshot: FileEditorSnapshot): FileConflictCallout | null { + if (snapshot.status !== "conflict") return null; + switch (snapshot.observedVersion.status) { + case "ready": + return { kind: "changed", canOverwrite: snapshot.modified }; + case "missing": + return { kind: "deleted" }; + case "error": + return { kind: "checkFailed" }; + default: + return assertNever(snapshot.observedVersion); + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected file version: ${JSON.stringify(value)}`); +} + function detectLineSeparator(content: string): FileLineSeparator { for (let index = 0; index < content.length; index += 1) { const character = content.charCodeAt(index); diff --git a/packages/app/src/file-pane/live-file.ts b/packages/app/src/file-pane/live-file.ts deleted file mode 100644 index c76d66afbb..0000000000 --- a/packages/app/src/file-pane/live-file.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { FileVersion } from "@getpaseo/protocol/messages"; -import { useFetchQuery } from "@/data/query"; - -export function useLiveFile(input: { - client: DaemonClient | null; - serverId: string; - cwd: string | null; - path: string | null; - enabled: boolean; - liveUpdates: boolean; -}) { - const queryClient = useQueryClient(); - const [subscriptionReady, setSubscriptionReady] = useState(!input.liveUpdates); - const [version, setVersion] = useState(null); - const latestVersion = useRef(null); - const queryKey = useMemo( - () => ["workspaceFile", input.serverId, input.cwd, input.path] as const, - [input.cwd, input.path, input.serverId], - ); - - useEffect(() => { - latestVersion.current = null; - setVersion(null); - const { client, cwd, path } = input; - if (!input.liveUpdates || !client || !cwd || !path || !input.enabled) { - setSubscriptionReady(!input.liveUpdates); - return; - } - let disposed = false; - let unsubscribe: (() => void) | null = null; - setSubscriptionReady(false); - void (async () => { - try { - const subscription = await client.subscribeFile({ cwd, path }, (next) => { - if (disposed) return; - latestVersion.current = next; - setVersion(next); - void queryClient.invalidateQueries({ queryKey }); - }); - if (disposed) { - subscription.unsubscribe(); - return; - } - unsubscribe = subscription.unsubscribe; - latestVersion.current = subscription.initial; - setVersion(subscription.initial); - setSubscriptionReady(true); - } catch { - if (!disposed) setSubscriptionReady(true); - } - })(); - return () => { - disposed = true; - unsubscribe?.(); - }; - }, [ - input.client, - input.cwd, - input.enabled, - input.liveUpdates, - input.path, - queryClient, - queryKey, - input.serverId, - ]); - - const query = useFetchQuery({ - queryKey, - enabled: input.enabled && Boolean(input.client && input.cwd && input.path) && subscriptionReady, - queryFn: async (): Promise => { - if (!input.client || !input.cwd || !input.path) throw new Error("File unavailable."); - return input.client.readFile(input.cwd, input.path); - }, - dataShape: "value", - staleTimeMs: 5_000, - }); - - useEffect(() => { - const observed = latestVersion.current; - if ( - query.data && - observed?.status === "ready" && - query.data.modifiedAt !== observed.modifiedAt - ) { - void queryClient.invalidateQueries({ queryKey }); - } - }, [query.data, queryClient, queryKey]); - - return { query, version }; -} diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts new file mode 100644 index 0000000000..b8b4bf0253 --- /dev/null +++ b/packages/app/src/file-pane/live-file/hook.ts @@ -0,0 +1,48 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { LiveFileModel, type LiveFileSession } from "./model"; + +export function useLiveFile(input: { + client: DaemonClient | null; + cwd: string | null; + path: string | null; + enabled: boolean; + liveUpdates: boolean; +}) { + const [model] = useState(() => new LiveFileModel()); + const session = useMemo(() => { + if (!input.client) return null; + const client = input.client; + return { + subscribe(target, onVersion) { + return client.subscribeFile(target, onVersion); + }, + read(target) { + return client.readFile(target.cwd, target.path); + }, + }; + }, [input.client]); + + useEffect(() => { + if (!input.enabled || !session || !input.cwd || !input.path) { + model.close(); + return; + } + model.open({ + session, + target: { cwd: input.cwd, path: input.path }, + liveUpdates: input.liveUpdates, + }); + return () => model.close(); + }, [input.cwd, input.enabled, input.liveUpdates, input.path, model, session]); + + const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); + return { + file: snapshot.file, + error: snapshot.read.status === "error" ? snapshot.read.error : null, + isFetching: snapshot.read.status === "pending", + isRetrying: snapshot.read.status === "pending" && snapshot.read.requested, + refresh: model.refresh, + model, + }; +} diff --git a/packages/app/src/file-pane/live-file/model.test.ts b/packages/app/src/file-pane/live-file/model.test.ts new file mode 100644 index 0000000000..d29a7d5f2b --- /dev/null +++ b/packages/app/src/file-pane/live-file/model.test.ts @@ -0,0 +1,255 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import type { FileVersion } from "@getpaseo/protocol/messages"; +import { describe, expect, it, vi } from "vitest"; +import { LiveFileModel, type LiveFileSession, type LiveFileSubscription } from "./model"; + +interface Deferred { + promise: Promise; + resolve(value: T): void; + reject(error: Error): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +function ready(revision = "revision-1"): Extract { + return { + status: "ready", + cwd: "/workspace", + path: "file.ts", + size: 3, + modifiedAt: "2026-07-30T00:00:00.000Z", + revision, + }; +} + +function file(revision = "revision-1", content = "one"): FileReadResult { + return { + bytes: new TextEncoder().encode(content), + mime: "text/plain", + size: content.length, + path: "file.ts", + kind: "text", + modifiedAt: "2026-07-30T00:00:00.000Z", + revision, + }; +} + +class TestLiveFileSession implements LiveFileSession { + readonly reads: Deferred[] = []; + subscribeAttempts = 0; + private onVersion: ((version: FileVersion) => void) | null = null; + private readonly initial: FileVersion; + + constructor(initial: FileVersion = ready()) { + this.initial = initial; + } + + async subscribe( + _target: { cwd: string; path: string }, + onVersion: (version: FileVersion) => void, + ): Promise { + this.subscribeAttempts += 1; + this.onVersion = onVersion; + return { initial: this.initial, unsubscribe: () => (this.onVersion = null) }; + } + + read(): Promise { + const request = deferred(); + this.reads.push(request); + return request.promise; + } + + emit(version: FileVersion): void { + this.onVersion?.(version); + } +} + +class DeferredSubscribeSession implements LiveFileSession { + readonly reads: Deferred[] = []; + readonly subscription = deferred(); + private onVersion: ((version: FileVersion) => void) | null = null; + + subscribe( + _target: { cwd: string; path: string }, + onVersion: (version: FileVersion) => void, + ): Promise { + this.onVersion = onVersion; + return this.subscription.promise; + } + + read(): Promise { + const request = deferred(); + this.reads.push(request); + return request.promise; + } + + emit(version: FileVersion): void { + this.onVersion?.(version); + } +} + +class FlakySubscriptionSession extends TestLiveFileSession { + override async subscribe( + target: { cwd: string; path: string }, + onVersion: (version: FileVersion) => void, + ): Promise { + if (this.subscribeAttempts === 0) { + this.subscribeAttempts += 1; + throw new Error("Connection closed."); + } + return super.subscribe(target, onVersion); + } +} + +async function waitForRead( + session: { reads: Deferred[] }, + count: number, +): Promise { + await vi.waitFor(() => expect(session.reads).toHaveLength(count)); +} + +describe("LiveFileModel", () => { + it("recovers only from a read begun after a missing observation", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getSnapshot().file).toEqual(file())); + + model.refresh(); + await waitForRead(session, 2); + session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); + session.reads[1].resolve(file("revision-2", "two")); + + await waitForRead(session, 3); + expect(model.getSnapshot().version?.status).toBe("missing"); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); + + session.reads[2].resolve(file("revision-2", "two")); + await vi.waitFor(() => { + expect(model.getSnapshot()).toMatchObject({ + version: { status: "ready", revision: "revision-2" }, + file: { revision: "revision-2" }, + read: { status: "idle" }, + }); + }); + }); + + it("keeps an actionable error visible while refresh fails", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getSnapshot().file).toEqual(file())); + + session.emit({ + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "Requested path is not a file", + }); + await waitForRead(session, 2); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false }); + session.reads[1].reject(new Error("File unavailable.")); + + await vi.waitFor(() => { + expect(model.getSnapshot()).toMatchObject({ + version: { status: "error", error: "Requested path is not a file" }, + read: { status: "error", error: "File unavailable." }, + }); + }); + + model.refresh(); + await waitForRead(session, 3); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); + }); + + it("keeps an update that arrives before the subscription response", async () => { + const session = new DeferredSubscribeSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + + session.emit(ready("revision-2")); + session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} }); + + await waitForRead(session, 1); + expect(model.getVersion()).toMatchObject({ status: "ready", revision: "revision-2" }); + }); + + it("retries a failed subscription when the user refreshes", async () => { + const session = new FlakySubscriptionSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + expect(session.subscribeAttempts).toBe(1); + + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getVersion()?.status).toBe("ready")); + model.refresh(); + + await waitForRead(session, 2); + expect(session.subscribeAttempts).toBe(2); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); + }); + + it("reads once without subscribing when live updates are disabled", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: false, + }); + + await waitForRead(session, 1); + expect(session.subscribeAttempts).toBe(0); + expect(model.getSnapshot().read).toEqual({ status: "pending", requested: false }); + }); + + it("ignores a read that completes after the model closes", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + + model.close(); + session.reads[0].resolve(file()); + await Promise.resolve(); + + expect(model.getSnapshot()).toEqual({ + file: null, + version: null, + read: { status: "idle" }, + }); + }); +}); diff --git a/packages/app/src/file-pane/live-file/model.ts b/packages/app/src/file-pane/live-file/model.ts new file mode 100644 index 0000000000..478742329a --- /dev/null +++ b/packages/app/src/file-pane/live-file/model.ts @@ -0,0 +1,257 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import type { FileVersion } from "@getpaseo/protocol/messages"; + +export interface LiveFileTarget { + cwd: string; + path: string; +} + +export interface LiveFileSubscription { + initial: FileVersion; + unsubscribe(): void; +} + +export interface LiveFileSession { + subscribe( + target: LiveFileTarget, + onVersion: (version: FileVersion) => void, + ): Promise; + read(target: LiveFileTarget): Promise; +} + +export type LiveFileReadState = + | { status: "idle" } + | { status: "pending"; requested: boolean } + | { status: "error"; error: string }; + +export interface LiveFileSnapshot { + file: FileReadResult | null; + version: FileVersion | null; + read: LiveFileReadState; +} + +const idleSnapshot: LiveFileSnapshot = { + file: null, + version: null, + read: { status: "idle" }, +}; + +export class LiveFileModel { + private readonly listeners = new Set<() => void>(); + private snapshot: LiveFileSnapshot = idleSnapshot; + private session: LiveFileSession | null = null; + private target: LiveFileTarget | null = null; + private liveUpdates = false; + private unsubscribe: (() => void) | null = null; + private subscriptionPending = false; + private generation = 0; + private issuedReadSequence = 0; + private activeReadSequence: number | null = null; + private activeReadRequested = false; + private observedAfterReadSequence: number | null = null; + private refreshQueued = false; + private requestedRefreshQueued = false; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): LiveFileSnapshot => this.snapshot; + + getVersion = (): FileVersion | null => this.snapshot.version; + + open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void { + this.reset(); + this.session = input.session; + this.target = input.target; + this.liveUpdates = input.liveUpdates; + const generation = this.generation; + if (!input.liveUpdates) { + this.scheduleRead(false); + return; + } + void this.startSubscription(generation); + } + + close(): void { + this.reset(); + } + + refresh = (): void => { + if (!this.session || !this.target) return; + if (this.liveUpdates && !this.unsubscribe && !this.subscriptionPending) { + void this.startSubscription(this.generation); + } + this.scheduleRead(true); + }; + + private scheduleRead(requested: boolean): void { + if (this.activeReadSequence !== null) { + this.refreshQueued = true; + this.activeReadRequested ||= requested; + this.requestedRefreshQueued ||= requested; + if (requested && this.snapshot.read.status === "pending") { + this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested: true } }); + } + return; + } + this.startRead(requested); + } + + private reset(): void { + this.generation += 1; + this.unsubscribe?.(); + this.unsubscribe = null; + this.session = null; + this.target = null; + this.liveUpdates = false; + this.subscriptionPending = false; + this.issuedReadSequence = 0; + this.activeReadSequence = null; + this.activeReadRequested = false; + this.observedAfterReadSequence = null; + this.refreshQueued = false; + this.requestedRefreshQueued = false; + this.setSnapshot(idleSnapshot); + } + + private async startSubscription(generation: number): Promise { + const session = this.session; + const target = this.target; + if (!session || !target) return; + this.subscriptionPending = true; + let receivedUpdate = false; + try { + const subscription = await session.subscribe(target, (version) => { + if (generation !== this.generation) return; + receivedUpdate = true; + this.observeVersion(version); + }); + if (generation !== this.generation) { + subscription.unsubscribe(); + return; + } + this.subscriptionPending = false; + this.unsubscribe = subscription.unsubscribe; + if (!receivedUpdate) this.observeVersion(subscription.initial); + } catch { + if (generation !== this.generation) return; + this.subscriptionPending = false; + this.scheduleRead(false); + } + } + + private observeVersion(version: FileVersion): void { + this.observedAfterReadSequence = this.issuedReadSequence; + this.setSnapshot({ ...this.snapshot, version }); + this.scheduleRead(false); + } + + private startRead(requested: boolean): void { + const session = this.session; + const target = this.target; + if (!session || !target) return; + const generation = this.generation; + const sequence = ++this.issuedReadSequence; + this.activeReadSequence = sequence; + this.activeReadRequested = requested; + this.refreshQueued = false; + this.requestedRefreshQueued = false; + this.setSnapshot({ ...this.snapshot, read: { status: "pending", requested } }); + void session.read(target).then( + (file) => this.finishRead({ generation, sequence, target, file }), + (error) => this.failRead({ generation, sequence, target, error }), + ); + } + + private finishRead(input: { + generation: number; + sequence: number; + target: LiveFileTarget; + file: FileReadResult; + }): void { + if (!this.isActiveRead(input.generation, input.sequence)) return; + const requested = this.activeReadRequested; + this.activeReadSequence = null; + this.activeReadRequested = false; + if (this.readPredatesLatestObservation(input.sequence)) { + this.startRead(requested || this.takeQueuedRequested()); + return; + } + this.observedAfterReadSequence = null; + this.setSnapshot({ + file: input.file, + version: readyVersion(input.target, input.file), + read: { status: "idle" }, + }); + this.startQueuedRead(); + } + + private failRead(input: { + generation: number; + sequence: number; + target: LiveFileTarget; + error: unknown; + }): void { + if (!this.isActiveRead(input.generation, input.sequence)) return; + const requested = this.activeReadRequested; + this.activeReadSequence = null; + this.activeReadRequested = false; + if (this.readPredatesLatestObservation(input.sequence)) { + this.startRead(requested || this.takeQueuedRequested()); + return; + } + const message = errorMessage(input.error); + let version = this.snapshot.version; + if (version?.status !== "missing" && version?.status !== "error") { + version = { status: "error", cwd: input.target.cwd, path: input.target.path, error: message }; + } + this.setSnapshot({ ...this.snapshot, version, read: { status: "error", error: message } }); + this.startQueuedRead(); + } + + private isActiveRead(generation: number, sequence: number): boolean { + return generation === this.generation && sequence === this.activeReadSequence; + } + + private readPredatesLatestObservation(sequence: number): boolean { + return this.observedAfterReadSequence !== null && sequence <= this.observedAfterReadSequence; + } + + private startQueuedRead(): void { + if (!this.refreshQueued) return; + this.startRead(this.takeQueuedRequested()); + } + + private takeQueuedRequested(): boolean { + const requested = this.requestedRefreshQueued; + this.refreshQueued = false; + this.requestedRefreshQueued = false; + return requested; + } + + private setSnapshot(snapshot: LiveFileSnapshot): void { + if (snapshot === this.snapshot) return; + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } +} + +function readyVersion( + target: LiveFileTarget, + file: FileReadResult, +): Extract { + return { + status: "ready", + cwd: target.cwd, + path: target.path, + size: file.size, + modifiedAt: file.modifiedAt, + revision: file.revision, + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index eb902eb330..f042d6628a 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -1,3 +1,4 @@ +import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { useCallback, @@ -8,7 +9,6 @@ import React, { useSyncExternalStore, } from "react"; import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import type { FileVersion } from "@getpaseo/protocol/messages"; import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native"; import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; @@ -33,10 +33,17 @@ import { useAppActivelyVisible } from "@/hooks/use-app-visible"; import { isFileQueryEnabled } from "@/components/file-pane-enabled"; import { isWeb } from "@/constants/platform"; import { useAppSettings } from "@/hooks/use-settings"; -import { useLiveFile } from "./live-file"; +import { useLiveFile } from "./live-file/hook"; import { FilePanelBar } from "./bar"; -import { FileEditorModel, type FileEditorFile } from "./editor/model"; +import { + FileEditorModel, + getFileConflictCallout, + type FileConflictCallout, + type FileEditorFile, +} from "./editor/model"; import { FileEditorView } from "./editor/view"; +import type { FileConflictAlertState } from "./conflict-alert"; +import type { LiveFileModel } from "./live-file/model"; import { confirmDialog } from "@/utils/confirm-dialog"; import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes"; import type { Theme } from "@/styles/theme"; @@ -427,9 +434,8 @@ export function FilePane({ isTabActive: isActive, isAppVisible, }); - const { query, version } = useLiveFile({ + const liveFile = useLiveFile({ client, - serverId, cwd: readTarget?.cwd ?? null, path: readTarget?.path ?? null, enabled, @@ -437,16 +443,17 @@ export function FilePane({ }); useEffect(() => { + if (!liveFile.file) return; let active = true; const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null; void (async () => { - const nextPreview = await createFilePanePreview(query.data ?? null); + const nextPreview = await createFilePanePreview(liveFile.file); if (active) setResolvedPreview({ key, ...nextPreview }); })(); return () => { active = false; }; - }, [query.data, readTarget]); + }, [liveFile.file, readTarget]); useEffect(() => setMarkdownMode("preview"), [readTarget?.path]); @@ -463,7 +470,7 @@ export function FilePane({ const canToggleMarkdownMode = isMarkdown && editable; const lineCount = preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined; - const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad")); + const errorMessage = getFileErrorMessage(liveFile.error, t("panels.file.failedToLoad")); return ( void; + retryingRead: boolean; + retryLabel: string; filename: string; markdownMode?: "preview" | "source"; onMarkdownModeChange?: (mode: "preview" | "source") => void; @@ -564,7 +581,9 @@ function FilePanePresentation({ cwd={readTarget.cwd} path={readTarget.path} preview={preview as TextExplorerFile} - version={version} + liveFile={liveFile} + onRetryRead={onRetryRead} + retryingRead={retryingRead} filename={filename} mode={markdownMode} onModeChange={onMarkdownModeChange} @@ -576,6 +595,19 @@ function FilePanePresentation({ ); } + if (errorMessage) { + return ( + + + {errorMessage} + + + + ); + } + return ( {preview ? ( @@ -586,12 +618,6 @@ function FilePanePresentation({ onModeChange={onMarkdownModeChange} /> ) : null} - {errorMessage ? ( - - {errorMessage} - - ) : null} - void; + retryingRead: boolean; filename: string; mode?: "preview" | "source"; onModeChange?: (mode: "preview" | "source") => void; @@ -662,23 +692,26 @@ function EditableFilePane({ }), [client, cwd, path], ); - const [model] = useState( - () => - new FileEditorModel({ - file: { - content: preview.content ?? "", - hasBom: preview.hasBom, - version: { - status: "ready", - cwd, - path, - size: preview.size, - modifiedAt: preview.modifiedAt, - }, + const [model] = useState(() => { + return new FileEditorModel({ + file: { + content: preview.content ?? "", + hasBom: preview.hasBom, + version: { + status: "ready", + cwd, + path, + size: preview.size, + modifiedAt: preview.modifiedAt, }, - session, - }), - ); + }, + session, + }); + }); + useEffect(() => { + model.connectFileVersions(liveFile); + return () => model.disconnectFileVersions(); + }, [liveFile, model]); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]); usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave }); @@ -711,11 +744,12 @@ function EditableFilePane({ ); useEffect(() => () => model.dispose(), [model]); - useEffect(() => { - if (version) model.receiveFileVersion(version); - }, [model, version]); const handleReload = useCallback(() => { + if (!snapshot.modified) { + void model.reload(); + return; + } void (async () => { const confirmed = await confirmDialog({ title: t("panels.file.editor.reloadTitle"), @@ -725,8 +759,15 @@ function EditableFilePane({ }); if (confirmed) void model.reload(); })(); - }, [model, t]); + }, [model, snapshot.modified, t]); const handleOverwrite = useCallback(() => void model.overwrite(), [model]); + const conflict = fileConflictAlertState({ + callout: getFileConflictCallout(snapshot), + onOverwrite: handleOverwrite, + onReload: handleReload, + onRetry: onRetryRead, + retrying: retryingRead, + }); const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []); const renderedPreview = useMemo( () => ({ @@ -750,9 +791,7 @@ function EditableFilePane({ editorStatus={snapshot.status} cursor={showSource ? cursor : undefined} vimMode={showSource ? vimMode : null} - conflictUnavailable={snapshot.observedVersion.status !== "ready"} - onOverwrite={handleOverwrite} - onReload={handleReload} + conflict={conflict} mode={mode} onModeChange={onModeChange} /> @@ -781,6 +820,26 @@ function EditableFilePane({ ); } +function fileConflictAlertState(input: { + callout: FileConflictCallout | null; + onOverwrite(): void; + onReload(): void; + onRetry(): void; + retrying: boolean; +}): FileConflictAlertState | undefined { + if (!input.callout) return undefined; + if (input.callout.kind === "deleted") return { kind: "deleted" }; + if (input.callout.kind === "checkFailed") { + return { kind: "checkFailed", retrying: input.retrying, onRetry: input.onRetry }; + } + return { + kind: "changed", + canOverwrite: input.callout.canOverwrite, + onReload: input.onReload, + onOverwrite: input.onOverwrite, + }; +} + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, @@ -791,6 +850,7 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, alignItems: "center", justifyContent: "center", + gap: theme.spacing[3], padding: theme.spacing[4], }, loadingText: { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 48d82e4257..c60d7ea16c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1491,7 +1491,9 @@ export const ar: TranslationResources = { cursor: "السطر {{line}}، العمود {{column}}", preview: "معاينة", source: "المصدر", - unavailableTitle: "الملف غير متاح على القرص", + deletedTitle: "تم حذف الملف من القرص", + checkFailedTitle: "تعذّر التحقق من الملف على القرص", + preservedDescription: "تم الاحتفاظ بالنسخة المفتوحة.", conflictDescription: "تم الاحتفاظ بالمحتوى المحلي. اختر الإصدار الذي تريد إبقاءه.", overwrite: "استبدال", reload: "إعادة تحميل", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a7fd2d1aaa..b3407ebf38 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1502,7 +1502,9 @@ export const en = { cursor: "Line {{line}}, column {{column}}", preview: "Preview", source: "Source", - unavailableTitle: "File unavailable on disk", + deletedTitle: "File deleted on disk", + checkFailedTitle: "Couldn't check file on disk", + preservedDescription: "The open copy is preserved.", conflictDescription: "The local buffer was preserved. Choose which version to keep.", overwrite: "Overwrite", reload: "Reload", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1abd7cc059..7c9f20eab7 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1534,7 +1534,9 @@ export const es: TranslationResources = { cursor: "Línea {{line}}, columna {{column}}", preview: "Vista previa", source: "Código fuente", - unavailableTitle: "Archivo no disponible en disco", + deletedTitle: "Archivo eliminado del disco", + checkFailedTitle: "No se pudo comprobar el archivo en disco", + preservedDescription: "La copia abierta se ha conservado.", conflictDescription: "Se conservó el búfer local. Elige qué versión mantener.", overwrite: "Sobrescribir", reload: "Recargar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 58dc392cf1..6b6b138853 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1537,7 +1537,9 @@ export const fr: TranslationResources = { cursor: "Ligne {{line}}, colonne {{column}}", preview: "Aperçu", source: "Source", - unavailableTitle: "Fichier indisponible sur le disque", + deletedTitle: "Fichier supprimé du disque", + checkFailedTitle: "Impossible de vérifier le fichier sur le disque", + preservedDescription: "La copie ouverte est conservée.", conflictDescription: "Le contenu local a été conservé. Choisissez la version à garder.", overwrite: "Écraser", reload: "Recharger", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 0be4e6e982..21bc2106ec 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1507,7 +1507,9 @@ export const ja: TranslationResources = { cursor: "{{line}} 行、{{column}} 列", preview: "プレビュー", source: "ソース", - unavailableTitle: "ディスク上のファイルを利用できません", + deletedTitle: "ファイルはディスク上から削除されました", + checkFailedTitle: "ディスク上のファイルを確認できませんでした", + preservedDescription: "開いているコピーは保持されています。", conflictDescription: "ローカルの内容は保持されています。残すバージョンを選択してください。", overwrite: "上書き", reload: "再読み込み", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 51c9553fac..2eeae3863a 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1520,7 +1520,9 @@ export const ptBR: TranslationResources = { cursor: "Linha {{line}}, coluna {{column}}", preview: "Prévia", source: "Fonte", - unavailableTitle: "Arquivo indisponível no disco", + deletedTitle: "Arquivo excluído do disco", + checkFailedTitle: "Não foi possível verificar o arquivo no disco", + preservedDescription: "A cópia aberta foi preservada.", conflictDescription: "O conteúdo local foi preservado. Escolha qual versão manter.", overwrite: "Sobrescrever", reload: "Recarregar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index d546a92181..355c8657fe 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1525,7 +1525,9 @@ export const ru: TranslationResources = { cursor: "Строка {{line}}, столбец {{column}}", preview: "Просмотр", source: "Исходник", - unavailableTitle: "Файл недоступен на диске", + deletedTitle: "Файл удалён с диска", + checkFailedTitle: "Не удалось проверить файл на диске", + preservedDescription: "Открытая копия сохранена.", conflictDescription: "Локальный буфер сохранён. Выберите версию, которую нужно оставить.", overwrite: "Перезаписать", reload: "Перезагрузить", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index eb22734bdf..645d840763 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1472,7 +1472,9 @@ export const zhCN: TranslationResources = { cursor: "第 {{line}} 行,第 {{column}} 列", preview: "预览", source: "源代码", - unavailableTitle: "磁盘上的文件不可用", + deletedTitle: "文件已从磁盘删除", + checkFailedTitle: "无法检查磁盘上的文件", + preservedDescription: "打开的副本已保留。", conflictDescription: "本地内容已保留。请选择要保留的版本。", overwrite: "覆盖", reload: "重新加载", From 774b417cc5603973d3c0e6fbab947cc5f2bf0c2f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 16:39:41 +0800 Subject: [PATCH 22/46] Revise PR template for clarity and structure Updated the pull request template to clarify guidelines and checklist items. --- .github/PULL_REQUEST_TEMPLATE.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7d5039aed4..73ffec97c7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,19 +1,17 @@ ### Linked issue Closes # - - ### Type of change - [ ] Bug fix -- [ ] New feature (with prior issue + design alignment) +- [ ] New feature - [ ] Refactor / code improvement - [ ] Docs @@ -29,15 +27,13 @@ This is the section I read most carefully. I need to see that *you* tested this, - For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough. - For behavior changes: the actual steps you ran, and what you observed. - For bug fixes: how you reproduced the bug before, and confirmed it's fixed after. - -AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot. --> ### Checklist -- [ ] One focused change. Unrelated cleanups split out. +- [ ] One focused change - [ ] `npm run typecheck` passes - [ ] `npm run lint` passes -- [ ] `npm run format` ran (Biome) -- [ ] UI changes include screenshots or video for every affected platform +- [ ] `npm run format` passes +- [ ] QA evidence - [ ] Tests added or updated where it made sense From fe337b888df51d8f14008536e6fd75ddda44314d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 16:40:18 +0800 Subject: [PATCH 23/46] Update pull request template for type of change options --- .github/PULL_REQUEST_TEMPLATE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 73ffec97c7..e6395a836f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,8 @@ Closes # - [ ] Bug fix - [ ] New feature -- [ ] Refactor / code improvement +- [ ] Enhancement +- [ ] Refactor - [ ] Docs ### What does this PR do From eb5e8ba7c5444ad5fab785f807ff068604dac1a9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 16:40:49 +0800 Subject: [PATCH 24/46] Revise PR template to highlight CONTRIBUTING.md Updated the PR template to emphasize reading the CONTRIBUTING.md file. --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e6395a836f..cad0957112 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,7 +1,7 @@ ### Linked issue From f3fee73976b0f1075dca309e5a5ef53534d3107f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 12:08:43 +0200 Subject: [PATCH 25/46] Stop OpenCode cancels from killing the next turn (#2662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(opencode): prevent late aborts from canceling replacement turns OpenCode aborts are session-scoped, so provider idle alone does not make a session safe to reuse. Keep abort retries owned by the stopping turn and require both abort settlement and provider idle before dispatching the next runner operation. * fix(opencode): retain the stop barrier through provider idle Keep the stopping operation authoritative until both its session-scoped abort and provider-idle evidence settle, including stale idle-session interrupts. Also keep the real drift fixture credential-allowlisted and wait for command output pipes to drain. * test(opencode): launch Windows fixture shims safely * fix(opencode): recover stop observation safely * fix(opencode): preserve autonomous wake through stop * fix(opencode): retain deferred wake completion * fix(opencode): serialize stop boundary replay * test(opencode): use shared real e2e lane * fix(opencode): own stops started during replay * fix(opencode): retry stop observation on demand * fix(opencode): follow replacement stop boundaries * fix(opencode): retain wakes during stop recovery * fix(opencode): hold replay turns behind boundary * fix(opencode): filter canceled reconnect output * fix(opencode): bind status evidence to stop * fix(opencode): acknowledge owned cancellation * fix(opencode): reconcile stale busy evidence * fix(opencode): bind replay terminals to wakes * refactor(opencode): own the stop boundary in one explicit state The run boundary had grown into a mutable flag bag plus a raw-event buffer that was replayed and re-classified after the fact. Each new interleaving needed another predicate, and the classification kept losing autonomous wakes. Restore stopping as a turn-state variant that owns exactly two things: the session-scoped abort it issued (with its single owned retry) and the canceled run's terminal. Runner operations wait on both; a failed abort fails closed. Provider status reconciliation now ends the stop through the same transition an SSE terminal takes. The canceled run's residue is suppressed only up to that terminal. Events published afterwards are a new run by construction and take the normal live path, so a wake no longer waits for abort settlement and cannot be dropped by a retention heuristic. * fix(opencode): emit cancellation before adopting the next run The acknowledgement ran off a promise combinator, so a wake adopted from the event right after the terminal could reach subscribers before the canceled turn's turn_canceled. Acknowledge synchronously from whichever proof lands first instead. * docs(opencode): attach the stop doc comment to its interface * fix(opencode): retry the stop in progress instead of opening a second Pressing Stop again after an abort failed built a detached boundary: its fresh abort could succeed while the canceled turn kept waiting for an acknowledgement, so the daemon sat out the full cancel timeout before force-canceling. A repeated Stop now replaces the abort owned by the stop already in progress, which is what carries the acknowledgement and what lifts the fail-closed gate. * fix(opencode): accumulate every abort a stop has issued A repeated Stop replaced the stop's abort settlement instead of adding to it, so a first request still in flight — the slow-teardown case the cancel cap exists for — no longer held the gate. The replacement could start while that request was live and get cancelled by it, which is the race this boundary exists to close. Reuse now waits for every abort the stop issued, while only the newest can hold the gate closed. * fix(opencode): scope abort settlement to the session, not the stop Abort is session-scoped, so its settlement has to be too. Tracking it on the stop meant a new stop boundary silently dropped an older request that was still in flight, and that request could still cancel whatever run started next. The session now accumulates every abort it has issued; the stop is left owning only the run it is stopping. * fix(opencode): recheck the abort gate after waiting on it A waiting prompt read the settlement once, so a Stop issued while it waited was invisible to it: the older abort would settle, the prompt would start, and the newer one could still cancel it. Drain until the settlement observed is the current one. --- docs/providers.md | 8 + .../agent/providers/opencode-agent.test.ts | 717 +++++++++++++++++- .../server/agent/providers/opencode-agent.ts | 268 +++++-- .../providers/opencode-server-manager.test.ts | 17 +- .../providers/opencode/server-manager.ts | 4 + .../test-utils/test-opencode-harness.ts | 5 +- .../opencode-omo-autonomous.real.e2e.test.ts | 236 ++++++ .../opencode-omo-real-runtime.test.ts | 43 ++ .../daemon-e2e/opencode-omo-real-runtime.ts | 520 +++++++++++++ 9 files changed, 1730 insertions(+), 88 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/opencode-omo-autonomous.real.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.test.ts create mode 100644 packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts diff --git a/docs/providers.md b/docs/providers.md index 5bee5b40b2..b5723733e1 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -458,3 +458,11 @@ Tests use `isProviderAvailable(provider)` to skip when the binary or credentials **`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process. **Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor. + +**Session-scoped cancellation needs a stop boundary inside the provider.** Some agents cancel the whole session rather than one turn — OpenCode's `session.abort` is the example. A cancel that is still in flight when the next run starts will kill that replacement run, which is what makes "stop, then immediately prompt again" (`replaceRunning`, `notifyOnFinish` wakes, schedules) flaky. Own this in the provider session, not in `AgentManager`: + +- Model the stop as an explicit `stopping` turn-state variant carrying the canceled run's terminal and the cancellation the caller is still owed. Pressing Stop again retries the stop already in progress rather than opening a second one; never fire a detached retry, it will outlive its boundary. +- **Scope the cancel settlement the way the provider scopes the cancel.** If cancellation is session-scoped, so is its settlement: track it on the session, accumulating every request issued, and let it outlive the stop that issued it. A request still in flight lands on the runner whenever the server gets to it — however many stops have come and gone since. Scoping it to the current stop looks right and quietly drops older requests from the gate. Only the newest may hold the gate closed, since recovering from a failed cancel is what pressing Stop again is for. +- Gate the operations **the daemon issues** (prompt, slash command, summarize) on both the terminal and cancel settlement. Permission and question responses are not runner operations and must stay outside the gate, or an auto-approve deadlocks the stop. Runs the _provider_ starts on its own — plugin or autonomous wakes — are observed, not gated: the daemon does not choose when they begin, and holding their events back does not protect them from a cancel already in flight, it only hides a run that may already be dead. +- Fail closed: if the cancel never succeeded you never proved the run stopped, so refuse new runs until the next Stop issues a fresh cancel. `AgentManager` already turns a rejected `interrupt()` into a refused cancel. +- Suppress the canceled run's residue only until its authoritative terminal. Anything the provider publishes after that terminal is a new run by construction and must take the normal live path — buffering it and replaying it later is how autonomous/plugin wakes get lost. diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index b9af8193c7..ecc1852b63 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -125,11 +125,13 @@ function userMessageEvents(params: { ]; } -function assistantTurnEvents({ +function assistantMessageEvents({ sessionId = "session-1", + messageId = "msg_assistant", text = "Hello from OpenCode", }: { sessionId?: string; + messageId?: string; text?: string; } = {}): unknown[] { return [ @@ -137,7 +139,7 @@ function assistantTurnEvents({ type: "message.updated", properties: { info: { - id: "msg_assistant", + id: messageId, sessionID: sessionId, role: "assistant", }, @@ -147,13 +149,25 @@ function assistantTurnEvents({ type: "message.part.delta", properties: { sessionID: sessionId, - messageID: "msg_assistant", - partID: "prt_text", + messageID: messageId, + partID: messageId === "msg_assistant" ? "prt_text" : `part_${messageId}`, field: "text", delta: text, }, }, - { type: "session.idle", properties: { sessionID: sessionId } }, + ]; +} + +function assistantTurnEvents( + params: { + sessionId?: string; + messageId?: string; + text?: string; + } = {}, +): unknown[] { + return [ + ...assistantMessageEvents(params), + { type: "session.idle", properties: { sessionID: params.sessionId ?? "session-1" } }, ]; } @@ -1901,27 +1915,603 @@ describe("OpenCode adapter startTurn error handling", () => { } }); - test("retries abort and waits for provider idle before starting the next prompt", async () => { + test("waits for the stop abort and provider idle before starting the next prompt", async () => { const { parent: session, openCode } = await createParentSession("ses_unit_test"); + const retryStarted = createTestDeferred(); + const settleRetry = createTestDeferred(); + const oldIdleConsumed = createTestDeferred(); + const replacementCompleted = createTestDeferred(); + const events: AgentStreamEvent[] = []; openCode.sessionPromptAsyncEvents = []; openCode.sessionAbortImplementation = async () => { if (openCode.calls.sessionAbort.length === 1) { throw new Error("abort transport failed"); } + retryStarted.resolve(); + await settleRetry.promise; + return { data: true }; + }; + session.subscribe((event) => { + events.push(event); + if (event.type === "provider_subagent") { + oldIdleConsumed.resolve(); + } + if (event.type === "turn_completed") { + replacementCompleted.resolve(); + } + }); + try { + await session.startTurn("first"); + const interrupt = session.interrupt(); + await retryStarted.promise; + expect(openCode.calls.sessionPromptAsync).toHaveLength(1); + openCode.emitEvent({ type: "session.idle", properties: { sessionID: "ses_unit_test" }, }); - return { data: true }; - }; - try { - await session.startTurn("first"); - await session.interrupt(); + openCode.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_after_old_idle", + parentID: "ses_unit_test", + title: "Old idle drain marker", + directory: "/workspace/repo", + }, + }, + }); + await oldIdleConsumed.promise; + const replacement = session.startTurn("second"); + await new Promise((resolve) => setImmediate(resolve)); + + expect(openCode.calls.sessionAbort).toHaveLength(2); expect(openCode.calls.sessionPromptAsync).toHaveLength(1); - await session.startTurn("second"); - expect(openCode.calls.sessionAbort).toHaveLength(2); + settleRetry.resolve(); + await interrupt; + await replacement; expect(openCode.calls.sessionPromptAsync).toHaveLength(2); + + for (const event of assistantTurnEvents({ + sessionId: "ses_unit_test", + text: "Replacement completed safely.", + })) { + openCode.emitEvent(event); + } + await replacementCompleted.promise; + + expect( + turnEventSignatures(events.filter((event) => event.type !== "provider_subagent")), + ).toEqual([ + ["turn_started", "opencode-turn-0"], + ["turn_canceled", "opencode-turn-0"], + ["turn_started", "opencode-turn-1"], + ["timeline", "opencode-turn-1"], + ["turn_completed", "opencode-turn-1"], + ]); + } finally { + settleRetry.resolve(); + await session.close(); + } + }); + + test("streams an autonomous wake as soon as the canceled run reaches its terminal", async () => { + const { parent: session, openCode } = await createParentSession("ses_wake_after_terminal"); + const settleAbort = createTestDeferred(); + const events: AgentStreamEvent[] = []; + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => { + await settleAbort.promise; + return { data: true }; + }; + session.subscribe((event) => events.push(event)); + + try { + await session.startTurn("first"); + const interrupt = session.interrupt(); + await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1)); + + for (const event of assistantMessageEvents({ + sessionId: "ses_wake_after_terminal", + messageId: "msg_canceled_assistant", + text: "Canceled residue.", + })) { + openCode.emitEvent(event); + } + openCode.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_wake_after_terminal" }, + }); + for (const event of userMessageEvents({ + sessionId: "ses_wake_after_terminal", + messageId: "msg_wake_after_terminal", + text: "Wake after the canceled terminal", + })) { + openCode.emitEvent(event); + } + for (const event of assistantMessageEvents({ + sessionId: "ses_wake_after_terminal", + messageId: "msg_wake_assistant", + text: "Wake output streams live.", + })) { + openCode.emitEvent(event); + } + + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + turnId: "opencode-turn-1", + item: expect.objectContaining({ text: "Wake output streams live." }), + }), + ), + ); + expect(events).not.toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ text: "Canceled residue." }), + }), + ); + + settleAbort.resolve(); + await interrupt; + openCode.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_wake_after_terminal" }, + }); + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ type: "turn_completed", turnId: "opencode-turn-1" }), + ), + ); + await expect(session.startTurn("after the wake")).resolves.toEqual({ + turnId: "opencode-turn-2", + }); + } finally { + settleAbort.resolve(); + await session.close(); + } + }); + + test("preserves an autonomous wake that arrives after provider idle but before abort settlement", async () => { + const { parent: session, openCode } = await createParentSession("ses_deferred_autonomous"); + const settleAbort = createTestDeferred(); + const wakeEventsConsumed = createTestDeferred(); + const events: AgentStreamEvent[] = []; + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => { + await settleAbort.promise; + return { data: true }; + }; + session.subscribe((event) => { + events.push(event); + if ( + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.id === "ses_deferred_wake_drain" + ) { + wakeEventsConsumed.resolve(); + } + }); + + try { + await session.startTurn("first"); + openCode.emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: "prt_canceled_usage", + sessionID: "ses_deferred_autonomous", + messageID: "msg_canceled_assistant", + type: "step-finish", + tokens: { input: 100, output: 20, cache: { read: 50 } }, + }, + }, + }); + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ + inputTokens: 100, + outputTokens: 20, + cachedInputTokens: 50, + }), + }), + ), + ); + const interrupt = session.interrupt(); + await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1)); + openCode.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_deferred_autonomous" }, + }); + openCode.emitEvent({ + type: "message.updated", + properties: { + info: { + id: "msg_plugin_wake", + sessionID: "ses_deferred_autonomous", + role: "user", + }, + }, + }); + openCode.emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: "prt_autonomous_usage", + sessionID: "ses_deferred_autonomous", + messageID: "msg_autonomous_assistant", + type: "step-finish", + tokens: { input: 7, output: 3 }, + }, + }, + }); + for (const event of assistantTurnEvents({ + sessionId: "ses_deferred_autonomous", + text: "Autonomous wake completed.", + })) { + openCode.emitEvent(event); + } + openCode.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_deferred_wake_drain", + parentID: "ses_deferred_autonomous", + title: "Deferred wake drain marker", + directory: "/workspace/repo", + }, + }, + }); + await wakeEventsConsumed.promise; + + settleAbort.resolve(); + await interrupt; + await new Promise((resolve) => setImmediate(resolve)); + + expect(events).toContainEqual({ + type: "turn_started", + provider: "opencode", + turnId: "opencode-turn-1", + }); + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "turn_completed", + provider: "opencode", + turnId: "opencode-turn-1", + usage: expect.objectContaining({ + inputTokens: 7, + outputTokens: 3, + contextWindowUsedTokens: 10, + }), + }), + ); + const autonomousCompletion = events.find( + (event) => event.type === "turn_completed" && event.turnId === "opencode-turn-1", + ); + expect(autonomousCompletion?.type).toBe("turn_completed"); + if (autonomousCompletion?.type === "turn_completed") { + expect(autonomousCompletion.usage?.cachedInputTokens).toBeUndefined(); + } + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + turnId: "opencode-turn-1", + item: expect.objectContaining({ + type: "assistant_message", + text: "Autonomous wake completed.", + }), + }), + ); + await expect(session.startTurn("after autonomous wake")).resolves.toEqual({ + turnId: "opencode-turn-2", + }); + } finally { + settleAbort.resolve(); + await session.close(); + } + }); + + test("waits for an idle-session abort before starting a prompt", async () => { + const { parent: session, openCode } = await createParentSession("ses_idle_abort"); + const settleAbort = createTestDeferred(); + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => { + await settleAbort.promise; + return { data: true }; + }; + + try { + const interrupt = session.interrupt(); + const prompt = session.startTurn("next"); + await new Promise((resolve) => setImmediate(resolve)); + expect(openCode.calls.sessionPromptAsync).toHaveLength(0); + + settleAbort.resolve(); + await interrupt; + await prompt; + expect(openCode.calls.sessionPromptAsync).toHaveLength(1); + } finally { + settleAbort.resolve(); + await session.close(); + } + }); + + test("does not acknowledge cancellation when both abort attempts fail", async () => { + const { parent: session, openCode } = await createParentSession("ses_failed_idle_abort"); + const events: AgentStreamEvent[] = []; + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => { + throw new Error("abort failed"); + }; + session.subscribe((event) => events.push(event)); + + try { + await session.startTurn("still running upstream"); + await expect(session.interrupt()).rejects.toThrow("abort failed"); + + await expect(session.startTurn("unsafe replacement")).rejects.toThrow("abort failed"); + expect(openCode.calls.sessionAbort).toHaveLength(2); + expect(openCode.calls.sessionPromptAsync).toHaveLength(1); + expect(events.map((event) => event.type)).toEqual(["turn_started"]); + } finally { + await session.close(); + } + }); + + test("acknowledges the canceled turn when a repeated Stop finally aborts", async () => { + const { parent: session, openCode } = await createParentSession("ses_retried_stop"); + const events: AgentStreamEvent[] = []; + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => { + if (openCode.calls.sessionAbort.length <= 2) { + throw new Error("abort failed"); + } + return { data: true }; + }; + session.subscribe((event) => events.push(event)); + + try { + await session.startTurn("still running upstream"); + await expect(session.interrupt()).rejects.toThrow("abort failed"); + expect(events.map((event) => event.type)).toEqual(["turn_started"]); + + await session.interrupt(); + + expect(openCode.calls.sessionAbort).toHaveLength(3); + expect(events).toContainEqual({ + type: "turn_canceled", + provider: "opencode", + reason: "interrupted", + turnId: "opencode-turn-0", + }); + + openCode.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_retried_stop" }, + }); + await expect(session.startTurn("replacement")).resolves.toEqual({ + turnId: "opencode-turn-1", + }); + expect(openCode.calls.sessionPromptAsync).toHaveLength(2); + } finally { + await session.close(); + } + }); + + test("keeps a waiting replacement behind an abort issued while it waits", async () => { + const { parent: session, openCode } = await createParentSession("ses_stop_during_wait"); + const settleFirstAbort = createTestDeferred(); + const settleSecondAbort = createTestDeferred(); + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => { + await (openCode.calls.sessionAbort.length === 1 + ? settleFirstAbort.promise + : settleSecondAbort.promise); + return { data: true }; + }; + + let replacement: Promise<{ turnId: string }> | undefined; + try { + await session.startTurn("first"); + const firstStop = session.interrupt(); + void firstStop.catch(() => undefined); + await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1)); + + replacement = session.startTurn("replacement"); + openCode.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_stop_during_wait" }, + }); + + const secondStop = session.interrupt(); + void secondStop.catch(() => undefined); + await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(2)); + + settleFirstAbort.resolve(); + await firstStop; + await new Promise((resolve) => setImmediate(resolve)); + expect(openCode.calls.sessionPromptAsync).toHaveLength(1); + + settleSecondAbort.resolve(); + await secondStop; + await replacement; + expect(openCode.calls.sessionPromptAsync).toHaveLength(2); + } finally { + settleFirstAbort.resolve(); + settleSecondAbort.resolve(); + await replacement?.catch(() => undefined); + await session.close(); + } + }); + + test("keeps a replacement behind an abort left in flight by an earlier stop", async () => { + const { parent: session, openCode } = await createParentSession("ses_carried_abort"); + const settleFirstAbort = createTestDeferred(); + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => { + if (openCode.calls.sessionAbort.length === 1) { + await settleFirstAbort.promise; + } + return { data: true }; + }; + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + let replacement: Promise<{ turnId: string }> | undefined; + try { + await session.startTurn("first"); + const firstStop = session.interrupt(); + void firstStop.catch(() => undefined); + await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1)); + + openCode.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_carried_abort" }, + }); + for (const event of userMessageEvents({ + sessionId: "ses_carried_abort", + messageId: "msg_wake_between_stops", + text: "Autonomous wake between stops", + })) { + openCode.emitEvent(event); + } + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "turn_started", + provider: "opencode", + turnId: "opencode-turn-1", + }), + ); + + await session.interrupt(); + expect(openCode.calls.sessionAbort).toHaveLength(2); + openCode.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_carried_abort" }, + }); + + replacement = session.startTurn("replacement"); + await new Promise((resolve) => setImmediate(resolve)); + expect(openCode.calls.sessionPromptAsync).toHaveLength(1); + + settleFirstAbort.resolve(); + await firstStop; + await replacement; + expect(openCode.calls.sessionPromptAsync).toHaveLength(2); + } finally { + settleFirstAbort.resolve(); + await replacement?.catch(() => undefined); + await session.close(); + } + }); + + test("keeps a replacement behind an earlier Stop's abort that is still in flight", async () => { + const { parent: session, openCode } = await createParentSession("ses_overlapping_stops"); + const settleFirstAbort = createTestDeferred(); + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => { + if (openCode.calls.sessionAbort.length === 1) { + await settleFirstAbort.promise; + } + return { data: true }; + }; + + let replacement: Promise<{ turnId: string }> | undefined; + try { + await session.startTurn("first"); + const firstStop = session.interrupt(); + void firstStop.catch(() => undefined); + await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1)); + + await session.interrupt(); + expect(openCode.calls.sessionAbort).toHaveLength(2); + openCode.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_overlapping_stops" }, + }); + + replacement = session.startTurn("replacement"); + await new Promise((resolve) => setImmediate(resolve)); + expect(openCode.calls.sessionPromptAsync).toHaveLength(1); + + settleFirstAbort.resolve(); + await firstStop; + await replacement; + expect(openCode.calls.sessionPromptAsync).toHaveLength(2); + } finally { + settleFirstAbort.resolve(); + await replacement?.catch(() => undefined); + await session.close(); + } + }); + + test("does not retry an owned abort after the session closes", async () => { + const { parent: session, openCode } = await createParentSession("ses_close_pending_abort"); + const settleOwnedAbort = createTestDeferred(); + openCode.sessionAbortImplementation = async () => { + if (openCode.calls.sessionAbort.length === 1) { + await settleOwnedAbort.promise; + throw new Error("late abort failure"); + } + return { data: true }; + }; + + const interrupt = session.interrupt(); + await vi.waitFor(() => expect(openCode.calls.sessionAbort).toHaveLength(1)); + await session.close(); + expect(openCode.calls.sessionAbort).toHaveLength(2); + + settleOwnedAbort.resolve(); + await expect(interrupt).rejects.toThrow("late abort failure"); + expect(openCode.calls.sessionAbort).toHaveLength(2); + }); + + test("fails closed when the SDK abort throws before returning a promise", async () => { + const openCode = new TestOpenCodeClient(); + const sdkClient = openCode.asSdkClient(); + const workingAbort = sdkClient.session.abort; + sdkClient.session.abort = (() => { + throw new Error("synchronous abort failure"); + }) as typeof sdkClient.session.abort; + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/workspace/repo" }, + sdkClient, + "ses_sync_abort_failure", + createTestLogger(), + ); + + try { + await expect(session.interrupt()).rejects.toThrow("synchronous abort failure"); + + await expect(session.startTurn("unsafe replacement")).rejects.toThrow( + "synchronous abort failure", + ); + expect(openCode.calls.sessionPromptAsync).toHaveLength(0); + } finally { + sdkClient.session.abort = workingAbort; + await session.close(); + } + }); + + test("fails closed when idle-session abort attempts return SDK errors", async () => { + const { parent: session, openCode } = await createParentSession("ses_error_idle_abort"); + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => ({ + error: new Error("abort response failed"), + }); + + try { + await expect(session.interrupt()).rejects.toThrow("abort response failed"); + + await expect(session.startTurn("unsafe replacement")).rejects.toThrow( + "abort response failed", + ); + expect(openCode.calls.sessionAbort).toHaveLength(2); + expect(openCode.calls.sessionPromptAsync).toHaveLength(0); } finally { await session.close(); } @@ -1948,21 +2538,91 @@ describe("OpenCode adapter startTurn error handling", () => { } }); - test("reconnects and confirms provider idle when the event stream ends while stopping", async () => { + test("keeps waiting for the stop terminal when the reconnect status probe fails", async () => { const firstStreamEnd = createTestDeferred(); + const settleAbort = createTestDeferred(); + const releaseTerminal = createTestDeferred(); let subscriptionCount = 0; const { parent: session, openCode } = await createParentSession( - "ses_stream_reconnect", + "ses_stop_status_probe_failure", (client) => { client.globalEventImplementation = async (options) => { subscriptionCount += 1; const signal = (options as { signal: AbortSignal }).signal; - const end = subscriptionCount === 1 ? firstStreamEnd.promise : waitForAbort(signal); return { stream: { async *[Symbol.asyncIterator]() { yield { type: "server.connected", properties: {} }; - await end; + if (subscriptionCount === 1) { + await firstStreamEnd.promise; + return; + } + await releaseTerminal.promise; + yield { + type: "session.idle", + properties: { sessionID: "ses_stop_status_probe_failure" }, + }; + await waitForAbort(signal); + }, + }, + }; + }; + client.sessionStatusImplementation = async () => { + throw new Error("provider status unavailable"); + }; + }, + ); + openCode.sessionPromptAsyncEvents = []; + openCode.sessionAbortImplementation = async () => { + await settleAbort.promise; + return { data: true }; + }; + + try { + await session.startTurn("first"); + const interrupt = session.interrupt(); + const replacement = session.startTurn("second"); + firstStreamEnd.resolve(); + await vi.waitFor(() => expect(openCode.calls.sessionStatus).toHaveLength(1)); + + settleAbort.resolve(); + await interrupt; + await new Promise((resolve) => setImmediate(resolve)); + + expect(openCode.calls.globalEvent).toHaveLength(2); + expect(openCode.calls.sessionStatus).toHaveLength(1); + + releaseTerminal.resolve(); + await expect(replacement).resolves.toEqual({ turnId: "opencode-turn-1" }); + expect(openCode.calls.sessionPromptAsync).toHaveLength(2); + expect(openCode.calls.globalEvent).toHaveLength(2); + } finally { + settleAbort.resolve(); + releaseTerminal.resolve(); + await session.close(); + } + }, 15_000); + + test("does not reconnect the stop observer while closing", async () => { + const firstStreamEnd = createTestDeferred(); + const streamSignals: AbortSignal[] = []; + let subscriptionCount = 0; + const { parent: session, openCode } = await createParentSession( + "ses_close_while_stopping", + (client) => { + client.globalEventImplementation = async (options) => { + subscriptionCount += 1; + const signal = (options as { signal: AbortSignal }).signal; + streamSignals.push(signal); + return { + stream: { + async *[Symbol.asyncIterator]() { + yield { type: "server.connected", properties: {} }; + if (subscriptionCount === 1) { + await firstStreamEnd.promise; + return; + } + await waitForAbort(signal); }, }, }; @@ -1974,18 +2634,23 @@ describe("OpenCode adapter startTurn error handling", () => { try { await session.startTurn("first"); await session.interrupt(); - - const secondTurn = session.startTurn("second"); + const replacement = session.startTurn("second"); + void replacement.catch(() => undefined); firstStreamEnd.resolve(); - - await expect(secondTurn).resolves.toEqual({ turnId: "opencode-turn-1" }); - expect(openCode.calls.globalEvent).toHaveLength(2); - expect(openCode.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]); - expect(openCode.calls.sessionPromptAsync).toHaveLength(2); - } finally { await session.close(); + + await expect(replacement).rejects.toThrow("OpenCode session is closed"); + expect(openCode.calls.globalEvent).toHaveLength(1); + } finally { + for (const signal of streamSignals) { + if (!signal.aborted) { + (signal as AbortSignal & { dispatchEvent: (event: Event) => boolean }).dispatchEvent( + new Event("abort"), + ); + } + } } - }, 15_000); + }); }); describe("OpenCodeAgentClient env", () => { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 850cd0b4e2..99c542beaf 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -2760,7 +2760,25 @@ function createDeferred(): Deferred { type OpenCodeTurnState = | { status: "idle" } | { status: "running"; turnId: string } - | { status: "stopping"; idle: Deferred }; + | { status: "stopping"; stop: OpenCodeStop }; + +type OpenCodeRunnerStatus = "idle" | "busy" | "retry"; + +/** + * One in-flight stop of the OpenCode session runner. + * + * A stop tracks the run it is stopping: the terminal that run still owes, and + * the cancellation the caller is still owed. The aborts it issues are tracked by + * the session instead, because OpenCode's abort is session-scoped rather than + * turn-scoped and can outlive the stop that issued it. The runner is reusable + * only once both the terminal and every issued abort have settled. + */ +interface OpenCodeStop { + /** Foreground turn still owed a cancellation acknowledgement; cleared once emitted. */ + pendingCancellationTurnId: string | null; + /** Resolves when the canceled run publishes its authoritative terminal. */ + readonly terminal: Deferred; +} function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null { const record = readOpenCodeRecord(event); @@ -2913,6 +2931,12 @@ class OpenCodeAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; private turnState: OpenCodeTurnState = { status: "idle" }; + /** + * Settlement of every session-scoped abort issued so far. It outlives the stop + * that issued it because a request still in flight can cancel a replacement + * run, and a rejection means we never proved the runner stopped. + */ + private abortSettlement: Promise = Promise.resolve(); private readonly runningToolCalls = new Map(); private subAgentsByCallId = new Map(); private subAgentCallIdByChildSessionId = new Map(); @@ -3009,22 +3033,31 @@ class OpenCodeAgentSession implements AgentSession { async interrupt(): Promise { const turnId = this.activeForegroundTurnId; this.abortController?.abort(); - if (turnId) { - this.beginStoppingTurn(turnId); - } + const abort = this.issueStop(turnId); // COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until // the running tool actually stops, which can be tens of seconds for // long-running tools. Cap the wait so the user-visible cancel lands // quickly while still giving OpenCode a chance to confirm the abort // cleanly. Drop the timeout once upstream returns abort acknowledgement // before tool teardown. - const abortPromise = this.abortSession(turnId, "interrupt"); - await withTimeout(abortPromise, 2_000, "OpenCode session.abort").catch((error) => { - this.logger.warn( - { err: error, sessionId: this.sessionId, turnId }, - "OpenCode session.abort exceeded the cancel cap; proceeding with local cancel", - ); - }); + const settledAbort = abort.then( + () => undefined, + (error: unknown) => error, + ); + // Only the cap is tolerated. A settled failure means the runner may still be + // going, and the caller must hear about it. + const abortFailure = await withTimeout(settledAbort, 2_000, "OpenCode session.abort").catch( + (error) => { + this.logger.warn( + { err: error, sessionId: this.sessionId, turnId }, + "OpenCode session.abort did not settle within the cancel cap", + ); + return undefined; + }, + ); + if (abortFailure !== undefined) { + throw abortFailure; + } } async revertBoth(input: { messageId: string }): Promise { @@ -3042,73 +3075,118 @@ class OpenCodeAgentSession implements AgentSession { sessionID: this.sessionId, directory: this.config.cwd, }) - .then(() => undefined) + .then((response) => { + if (response.error) { + throw new Error(toDiagnosticErrorMessage(response.error)); + } + return undefined; + }) .catch((error) => { this.logger.warn( { err: error, sessionId: this.sessionId, turnId, reason }, "OpenCode session.abort rejected", ); + throw error; }); } + /** + * Gate every runner-affecting operation on the previous stop. OpenCode runs one + * runner per session and aborts it session-wide, so a replacement may start + * only once the canceled run published its terminal and our own abort settled. + * A failed abort never proved the runner stopped, so it fails closed until the + * next Stop issues a fresh one. + */ + private async awaitRunnerQuiescence(): Promise { + const providerIdle = this.waitUntilProviderIdle(); + // A failed abort is decisive on its own, so let it reject this wait without + // leaving the still-running observation unhandled. + void providerIdle.catch(() => undefined); + let observed = this.abortSettlement; + await Promise.all([observed, providerIdle]); + // Stop can issue a further abort while we waited, and the settlement is + // replaced rather than mutated, so drain until what we observed is current. + while (this.abortSettlement !== observed) { + observed = this.abortSettlement; + await observed; + } + } + private async waitUntilProviderIdle(): Promise { if (this.turnState.status !== "stopping") { return; } - - const stopping = this.turnState; - // OpenCode creates prompts before joining its single session runner. Sending - // while that runner is stopping can strand the new prompt behind the old - // run, so only provider-confirmed idle makes the session reusable. await withTimeout( - this.observeProviderStopBoundary(stopping), + this.observeProviderStopBoundary(this.turnState.stop), OPENCODE_PENDING_ABORT_START_TIMEOUT_MS, "OpenCode previous turn to stop", ); } - private async observeProviderStopBoundary( - stopping: Extract, - ): Promise { - while (this.turnState === stopping) { - void this.abortSession(null, "turn_start_boundary"); + private async observeProviderStopBoundary(stop: OpenCodeStop): Promise { + while (this.isStopping(stop)) { const eventStreamTask = this.eventStreamTask; const boundary = await (eventStreamTask ? Promise.race([ - stopping.idle.promise.then(() => "idle" as const), + stop.terminal.promise.then(() => "terminal" as const), eventStreamTask.then( () => "stream_ended" as const, () => "stream_ended" as const, ), ]) : Promise.resolve("stream_ended" as const)); - if (boundary === "idle") { + if (boundary === "terminal") { return; } - - await this.ensureEventStreamReady(); - const response = await this.client.session.status({ directory: this.config.cwd }); - if (response.error) { - throw new Error( - `Failed to confirm OpenCode session status: ${toDiagnosticErrorMessage(response.error)}`, - ); - } - const statuses = readOpenCodeRecord(response.data); - if (!statuses) { - throw new Error("OpenCode returned an invalid session status response"); - } - const status = readOpenCodeRecord(statuses[this.sessionId]); - const statusType = readNonEmptyString(status?.type); - if (!status || statusType === "idle") { - this.finishStoppingTurn(); - return; - } - if (statusType !== "busy" && statusType !== "retry") { - throw new Error(`OpenCode returned an unknown session status '${statusType ?? "missing"}'`); - } + await this.reconcileStopWithProviderStatus(stop); } } + /** + * The event stream dropped mid-stop, so the canceled run's terminal may have + * gone with it. `session.status` is the provider's authority on whether the + * runner is still going, and an idle answer ends the stop through the same + * transition an SSE terminal takes. A failed probe is not evidence either way, + * so the stop keeps waiting on the replacement stream instead of rejecting a + * caller that a later terminal would have released. + */ + private async reconcileStopWithProviderStatus(stop: OpenCodeStop): Promise { + await this.ensureEventStreamReady(); + try { + if ((await this.readProviderRunnerStatus()) === "idle") { + this.finishStoppingTurn(stop); + } + } catch (error) { + this.logger.warn( + { err: error, sessionId: this.sessionId, turnId: stop.pendingCancellationTurnId }, + "Failed to reconcile the OpenCode stop with provider session status", + ); + } + } + + private async readProviderRunnerStatus(): Promise { + const response = await this.client.session.status({ directory: this.config.cwd }); + if (response.error) { + throw new Error( + `Failed to confirm OpenCode session status: ${toDiagnosticErrorMessage(response.error)}`, + ); + } + const statuses = readOpenCodeRecord(response.data); + if (!statuses) { + throw new Error("OpenCode returned an invalid session status response"); + } + const status = readOpenCodeRecord(statuses[this.sessionId]); + // OpenCode drops idle sessions from the status map entirely. + if (!status) { + return "idle"; + } + const statusType = readNonEmptyString(status.type); + if (statusType !== "idle" && statusType !== "busy" && statusType !== "retry") { + throw new Error(`OpenCode returned an unknown session status '${statusType ?? "missing"}'`); + } + return statusType; + } + async startTurn( prompt: AgentPromptInput, options?: AgentRunOptions, @@ -3116,7 +3194,7 @@ class OpenCodeAgentSession implements AgentSession { if (this.turnState.status === "running") { throw new Error("A foreground turn is already active"); } - await this.waitUntilProviderIdle(); + await this.awaitRunnerQuiescence(); if (this.turnState.status !== "idle") { throw new Error("OpenCode is still stopping the previous turn"); } @@ -3451,6 +3529,9 @@ class OpenCodeAgentSession implements AgentSession { } private ensureEventStreamReady(): Promise { + if (this.closed) { + return Promise.reject(new Error("OpenCode session is closed")); + } if (this.eventStreamReady) { return this.eventStreamReady.promise; } @@ -3580,8 +3661,11 @@ class OpenCodeAgentSession implements AgentSession { this.turnState.status === "stopping" && getOpenCodeEventSessionId(event) === this.sessionId ) { + // Residue of the canceled run must not surface as a new turn. Its terminal + // is the authoritative end of the stop, so anything OpenCode publishes + // afterwards belongs to a new run by construction and takes the live path. if (isOpenCodeTerminalEvent(event, this.sessionId)) { - this.finishStoppingTurn(); + this.finishStoppingTurn(this.turnState.stop); } this.traceOpenCode("provider.opencode.event.skip", { n: eventCount, @@ -3720,29 +3804,96 @@ class OpenCodeAgentSession implements AgentSession { this.notifySubscribers(event, turnId); } - private beginStoppingTurn(turnId: string): void { - if (this.turnState.status !== "running" || this.turnState.turnId !== turnId) { - return; + private isStopping(stop: OpenCodeStop): boolean { + return this.turnState.status === "stopping" && this.turnState.stop === stop; + } + + /** Stops the session runner and returns the abort this Stop issued. */ + private issueStop(turnId: string | null): Promise { + if (this.turnState.status === "stopping") { + // Stop pressed again during a stop retries that same stop. There is one + // runner per session, so a second boundary would race the first — and + // after a failed abort, a fresh one is both the only proof that can still + // acknowledge the canceled turn and the only way out of fail-closed. + return this.issueOwnedAbort(this.turnState.stop); + } + const stop: OpenCodeStop = { + pendingCancellationTurnId: turnId, + terminal: createDeferred(), + }; + const abort = this.issueOwnedAbort(stop); + if (turnId) { + this.synthesizeInterruptedToolCalls(turnId); + // An idle session has no run to observe, so only abort settlement gates + // reuse there. A running one also owes the canceled run's terminal. + this.turnState = { status: "stopping", stop }; } - this.synthesizeInterruptedToolCalls(turnId); this.pendingUserMessageText = null; this.pendingClientMessageId = null; this.abortController = null; - this.turnState = { status: "stopping", idle: createDeferred() }; + return abort; + } + + /** Issues one more session-scoped abort for this stop and returns it. */ + private issueOwnedAbort(stop: OpenCodeStop): Promise { + const abort = this.runOwnedAbort(stop.pendingCancellationTurnId); + // Abort is session-scoped, so its settlement is too: an older request lands + // on the runner whenever the server gets to it, however many stops have come + // and gone since. Only the newest abort may hold the gate closed, since + // recovering from a failed one is what pressing Stop again is for. + const stillInFlight = this.abortSettlement.catch(() => undefined); + this.abortSettlement = Promise.all([stillInFlight, abort]).then(() => undefined); + void this.abortSettlement.catch(() => undefined); + // Cancellation is acknowledged as soon as an owned abort succeeds, or when + // the provider publishes the canceled run's terminal. + void abort.then( + () => this.acknowledgeCancellation(stop), + () => undefined, + ); + return abort; + } + + private acknowledgeCancellation(stop: OpenCodeStop): void { + const turnId = stop.pendingCancellationTurnId; + if (!turnId) { + return; + } + stop.pendingCancellationTurnId = null; this.notifySubscribers( { type: "turn_canceled", provider: "opencode", reason: "interrupted" }, turnId, ); } - private finishStoppingTurn(): void { - if (this.turnState.status !== "stopping") { + /** + * Issues the session-scoped abort for one stop, retrying it once. The stop owns + * every abort it needs: a detached retry would outlive its own boundary and + * cancel whichever run happened to be current when it landed. + */ + private runOwnedAbort(turnId: string | null): Promise { + // The turn hop also converts a synchronous SDK throw into a rejection. + return Promise.resolve() + .then(() => this.abortSession(turnId, "interrupt")) + .catch((error) => { + if (this.closed) { + throw error; + } + return this.abortSession(turnId, "stop_retry"); + }); + } + + private finishStoppingTurn(stop: OpenCodeStop): void { + if (!this.isStopping(stop)) { return; } - const stopping = this.turnState; + // Acknowledge before leaving the stopping state so a successor run adopted + // from the very next event cannot start ahead of the cancellation. + this.acknowledgeCancellation(stop); resetOpenCodeTurnTrackingState(this.createTranslationState()); + const contextWindowMaxTokens = this.resolveSelectedModelContextWindowMaxTokens(); + this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {}; this.turnState = { status: "idle" }; - stopping.idle.resolve(); + stop.terminal.resolve(); } private trackToolCall(item: ToolCallTimelineItem): void { @@ -3991,9 +4142,6 @@ class OpenCodeAgentSession implements AgentSession { logger: this.logger, }); await this.deleteProviderSessionIfEphemeral(); - if (this.turnState.status === "stopping") { - this.turnState.idle.resolve(); - } this.turnState = { status: "idle" }; } finally { await this.releaseServer?.(); diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index de094dba69..38f698b473 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -27,6 +27,16 @@ afterEach(() => { }); describe("OpenCodeServerManager generations", () => { + test("uses an explicit base environment for the server process", async () => { + const baseEnv = { HOME: "/isolated/home", PATH: "/isolated/bin" }; + const { manager, runtime } = createTestManager([4091], { baseEnv }); + + const acquisition = await manager.acquireCurrent(); + + expect(runtime.spawnCalls[0]?.options.baseEnv).toEqual(baseEnv); + await acquisition.release(); + }); + test("rotation creates a new current server without killing a referenced old server", async () => { const { manager, runtime } = createTestManager([4101, 4102]); @@ -334,7 +344,11 @@ describe.runIf(process.platform === "win32")( function createTestManager( ports: number[], - options: { autoAnnounce?: boolean; opencodeHomeDir?: string } = {}, + options: { + autoAnnounce?: boolean; + baseEnv?: Record; + opencodeHomeDir?: string; + } = {}, ): { manager: OpenCodeServerManager; runtime: FakeOpenCodeServerRuntime; @@ -346,6 +360,7 @@ function createTestManager( return { manager: new OpenCodeServerManager({ logger: createTestLogger(), + baseEnv: options.baseEnv, managedProcesses: runtime.managedProcesses, portAllocator: runtime.allocatePort, resolveCommandPrefix: runtime.resolveCommandPrefix, diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index d765d3d27f..2baa07bbd3 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -53,6 +53,7 @@ export type OpenCodeServerProcessSpawner = ( export interface OpenCodeServerManagerOptions { logger: Logger; + baseEnv?: SpawnProcessOptions["baseEnv"]; runtimeSettings?: ProviderRuntimeSettings; managedProcesses?: ManagedProcessRegistry; terminateProcess?: ProcessTerminator; @@ -70,6 +71,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { private startPromise: Promise | null = null; private newServerPromise: Promise | null = null; private readonly logger: Logger; + private readonly baseEnv?: SpawnProcessOptions["baseEnv"]; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettingsKey: string; private readonly managedProcesses?: ManagedProcessRegistry; @@ -81,6 +83,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { constructor(options: OpenCodeServerManagerOptions) { this.logger = options.logger; + this.baseEnv = options.baseEnv; this.runtimeSettings = options.runtimeSettings; this.runtimeSettingsKey = JSON.stringify(this.runtimeSettings ?? {}); this.managedProcesses = options.managedProcesses; @@ -298,6 +301,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], ...createProviderEnvSpec({ + baseEnv: this.baseEnv, runtimeSettings: this.runtimeSettings, overlays: [launchEnv], }), diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index b59399279b..7433532f0f 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -124,6 +124,7 @@ export class TestOpenCodeClient { sessionPromptAsyncEvents: unknown[] = [idleEvent()]; sessionPromptAsyncResponse: OpenCodeResponse = {}; sessionStatusResponse: OpenCodeResponse = { data: {} }; + sessionStatusImplementation: ((parameters: unknown) => Promise) | null = null; sessionSummarizeEvents: unknown[] = [idleEvent()]; sessionSummarizeResponse: OpenCodeResponse = { data: {} }; sessionUpdateResponse: OpenCodeResponse = {}; @@ -260,7 +261,9 @@ export class TestOpenCodeClient { }, status: async (parameters: unknown) => { this.calls.sessionStatus.push(parameters); - return this.sessionStatusResponse; + return this.sessionStatusImplementation + ? await this.sessionStatusImplementation(parameters) + : this.sessionStatusResponse; }, summarize: async (parameters: unknown) => { this.calls.sessionSummarize.push(parameters); diff --git a/packages/server/src/server/daemon-e2e/opencode-omo-autonomous.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-omo-autonomous.real.e2e.test.ts new file mode 100644 index 0000000000..db3fb44822 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-omo-autonomous.real.e2e.test.ts @@ -0,0 +1,236 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, test } from "vitest"; + +import type { SessionOutboundMessage } from "../messages.js"; +import { createMessageCollector } from "../test-utils/message-collector.js"; +import { createOpenCodeOmoRealRuntime } from "./opencode-omo-real-runtime.js"; + +const CHILD_TOKEN = "PASEO_OMO_CHILD_READY"; +const ROOT_IDLE_TOKEN = "PASEO_OMO_ROOT_WAITING"; +const FINAL_TOKEN = "PASEO_OMO_AUTONOMOUS_FINAL"; +const ROOT_IDLE_BARRIER = ".paseo-omo-root-idle"; + +test("OpenCode remains idle while an OMO child works and completes the autonomous wake", async () => { + const runtime = await createOpenCodeOmoRealRuntime(); + const collector = createMessageCollector(runtime.client); + let passed = false; + + try { + const modeId = await resolveSisyphusMode(runtime.client, runtime.workspace); + const agent = await runtime.client.createAgent({ + provider: "opencode", + cwd: runtime.workspace, + title: "OpenCode OMO autonomous wake", + model: runtime.model, + modeId, + }); + + await runtime.client.sendMessage( + agent.id, + [ + "Use the OMO task tool exactly once with category quick and run_in_background=true.", + `Tell the child: use bash to wait until ${ROOT_IDLE_BARRIER} exists in the workspace, sleep 5, then return exactly ${CHILD_TOKEN}.`, + `After the background task starts, reply exactly ${ROOT_IDLE_TOKEN} and stop.`, + "When the background completion notification arrives, retrieve the task result with background_output.", + `After confirming ${CHILD_TOKEN}, reply exactly ${FINAL_TOKEN}.`, + ].join(" "), + ); + + const firstFinish = await runtime.client.waitForFinish(agent.id, 240_000); + expect(firstFinish.status).toBe("idle"); + const firstTimeline = await runtime.client.fetchAgentTimeline(agent.id, { limit: 240 }); + expect(assistantTexts(firstTimeline.entries).join("\n")).toContain(ROOT_IDLE_TOKEN); + await writeFile(join(runtime.workspace, ROOT_IDLE_BARRIER), "root idle\n", "utf8"); + + const completedChild = await waitForCompletedChildWithToken( + runtime.client, + agent.id, + CHILD_TOKEN, + ); + + const firstTerminal = firstTerminalIndex(collector.messages, agent.id); + await waitForMessage( + collector.messages, + (message, index) => + index > firstTerminal && isAgentStreamEvent(message, agent.id, "turn_started"), + "autonomous turn start", + ); + const wakeStartIndex = collector.messages.findIndex( + (message) => + isAgentStreamEvent(message, agent.id, "turn_started") && + collector.messages.indexOf(message) > firstTerminal, + ); + await waitForMessage( + collector.messages, + (message, index) => + index > wakeStartIndex && isAgentStreamEvent(message, agent.id, "turn_completed"), + "autonomous turn completion", + ); + const autonomousTerminalIndex = collector.messages.findIndex( + (message, index) => + index > wakeStartIndex && isAgentStreamEvent(message, agent.id, "turn_completed"), + ); + const finalTimeline = await runtime.client.fetchAgentTimeline(agent.id, { limit: 320 }); + expect(assistantTexts(finalTimeline.entries).at(-1)?.trim()).toBe(FINAL_TOKEN); + await waitForMessage( + collector.messages, + (message, index) => + index > autonomousTerminalIndex && isAgentStatus(message, agent.id, "idle"), + "idle after autonomous final output", + ); + const cancellationAfterWake = collector.messages + .slice(wakeStartIndex, autonomousTerminalIndex + 1) + .some((message) => isAgentStreamEvent(message, agent.id, "turn_canceled")); + const rootRunningIndex = collector.messages.findIndex((message) => + isAgentStatus(message, agent.id, "running"), + ); + const childRunningIndex = collector.messages.findIndex((message) => + isProviderSubagentStatus(message, agent.id, completedChild.id, "running"), + ); + const rootIdleIndex = collector.messages.findIndex( + (message, index) => index > childRunningIndex && isAgentStatus(message, agent.id, "idle"), + ); + const childCompletedIndex = collector.messages.findIndex((message) => + isProviderSubagentStatus(message, agent.id, completedChild.id, "completed"), + ); + expect(rootRunningIndex).toBeGreaterThan(-1); + expect(childRunningIndex).toBeGreaterThan(rootRunningIndex); + expect(rootIdleIndex).toBeGreaterThan(childRunningIndex); + expect(childCompletedIndex).toBeGreaterThan(rootIdleIndex); + expect(wakeStartIndex).toBeGreaterThan(-1); + expect(autonomousTerminalIndex).toBeGreaterThan(wakeStartIndex); + expect(cancellationAfterWake).toBe(false); + passed = true; + } catch (error) { + throw new Error( + `${error instanceof Error ? error.message : String(error)}. Retained real-test artifacts: ${runtime.artifacts}`, + { cause: error }, + ); + } finally { + collector.unsubscribe(); + await runtime.close(passed); + } +}, 2_100_000); + +function assistantTexts( + entries: Awaited< + ReturnType< + typeof import("../test-utils/daemon-client.js").DaemonClient.prototype.fetchAgentTimeline + > + >["entries"], +): string[] { + return entries.flatMap((entry) => + entry.item.type === "assistant_message" ? [entry.item.text] : [], + ); +} + +function isAgentStreamEvent( + message: SessionOutboundMessage, + agentId: string, + eventType: "turn_started" | "turn_completed" | "turn_failed" | "turn_canceled", +): boolean { + return ( + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === eventType + ); +} + +function isAgentStatus( + message: SessionOutboundMessage, + agentId: string, + status: "running" | "idle", +): boolean { + return ( + message.type === "agent_update" && + message.payload.kind === "upsert" && + message.payload.agent.id === agentId && + message.payload.agent.status === status + ); +} + +function isProviderSubagentStatus( + message: SessionOutboundMessage, + parentAgentId: string, + subagentId: string, + status: "running" | "completed", +): boolean { + return ( + message.type === "agent.provider_subagents.update" && + message.payload.kind === "upsert" && + message.payload.subagent.parentAgentId === parentAgentId && + message.payload.subagent.id === subagentId && + message.payload.subagent.status === status + ); +} + +function firstTerminalIndex(messages: SessionOutboundMessage[], agentId: string): number { + return messages.findIndex( + (message) => + isAgentStreamEvent(message, agentId, "turn_completed") || + isAgentStreamEvent(message, agentId, "turn_failed") || + isAgentStreamEvent(message, agentId, "turn_canceled"), + ); +} + +async function waitForCompletedChildWithToken( + client: import("../test-utils/daemon-client.js").DaemonClient, + parentAgentId: string, + token: string, +): Promise>["subagents"][number]> { + const deadline = Date.now() + 240_000; + while (Date.now() < deadline) { + const { subagents } = await client.listProviderSubagents(parentAgentId); + for (const subagent of subagents) { + const timeline = await client.fetchProviderSubagentTimeline(parentAgentId, subagent.id, { + direction: "tail", + limit: 0, + }); + if ( + subagent.status === "completed" && + (timeline.rows + .flatMap((row) => (row.item.type === "assistant_message" ? [row.item.text] : [])) + .join("") + .includes(token) || + timeline.rows.some( + (row) => + row.item.type === "tool_call" && + row.item.detail.type === "shell" && + row.item.detail.output?.includes(token), + )) + ) { + return subagent; + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for completed OpenCode child output ${token}`); +} + +async function waitForMessage( + messages: SessionOutboundMessage[], + predicate: (message: SessionOutboundMessage, index: number) => boolean, + description: string, +): Promise { + const deadline = Date.now() + 240_000; + while (!messages.some(predicate)) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${description}`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +async function resolveSisyphusMode( + client: import("../test-utils/daemon-client.js").DaemonClient, + cwd: string, +): Promise { + const { modes } = await client.listProviderModes("opencode", { cwd }); + const matches = modes.filter((mode) => mode.label.toLowerCase().startsWith("sisyphus")); + if (matches.length !== 1 || !matches[0]) { + throw new Error(`Expected one OMO Sisyphus mode, received: ${JSON.stringify(modes)}`); + } + return matches[0].id; +} diff --git a/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.test.ts b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.test.ts new file mode 100644 index 0000000000..18fefb1d4c --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; + +import { + resolveCommandLaunch, + resolveWindowsHomeEnv, + withArtifactLocation, +} from "./opencode-omo-real-runtime.js"; + +describe("resolveCommandLaunch", () => { + test("launches Windows npm command shims through cmd.exe", () => { + expect(resolveCommandLaunch("C:\\fixture runtime\\omo.cmd", ["install"], "win32")).toEqual({ + command: process.env.COMSPEC || "cmd.exe", + args: ["/d", "/s", "/c", '"C:\\fixture runtime\\omo.cmd" install'], + shell: false, + }); + }); + + test("launches Windows executables directly", () => { + expect(resolveCommandLaunch("C:\\fixture runtime\\opencode.exe", [], "win32")).toEqual({ + command: "C:\\fixture runtime\\opencode.exe", + args: [], + shell: false, + }); + }); +}); + +test("includes retained artifacts in fixture setup failures", () => { + expect( + withArtifactLocation(new Error("Command timed out: npm-install.log"), "/tmp/artifacts").message, + ).toBe("Command timed out: npm-install.log. Retained real-test artifacts: /tmp/artifacts"); +}); + +test("isolates Windows native home resolution", () => { + expect(resolveWindowsHomeEnv("C:\\fixture\\home", "C:\\fixture\\tmp")).toEqual({ + USERPROFILE: "C:\\fixture\\home", + HOMEDRIVE: "C:", + HOMEPATH: "\\fixture\\home", + APPDATA: "C:\\fixture\\home\\AppData\\Roaming", + LOCALAPPDATA: "C:\\fixture\\home\\AppData\\Local", + TEMP: "C:\\fixture\\tmp", + TMP: "C:\\fixture\\tmp", + }); +}); diff --git a/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts new file mode 100644 index 0000000000..923431f9bf --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-omo-real-runtime.ts @@ -0,0 +1,520 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Transform } from "node:stream"; + +import pino from "pino"; + +import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { OpenCodeServerManager } from "../agent/providers/opencode/server-manager.js"; +import { terminateWithTreeKill } from "../../utils/tree-kill.js"; +import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +const PINNED_OPENCODE_VERSION = "1.18.9"; +const PINNED_OMO_VERSION = "4.19.3"; +const DEFAULT_OPENROUTER_MODEL = "openrouter/google/gemini-2.5-flash"; +const NO_AUTH_MODEL = "opencode/big-pickle"; +const COMMAND_TIMEOUT_MS = 180_000; + +interface RuntimePaths { + root: string; + home: string; + paseoHomeRoot: string; + xdgConfig: string; + xdgData: string; + xdgCache: string; + temporary: string; + npmCache: string; + runtime: string; + artifacts: string; + workspace: string; +} + +interface CommandInput { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + artifactName: string; + artifacts: string; + secrets: string[]; + requiredOutput?: string; +} + +export interface OpenCodeOmoRealRuntime { + client: DaemonClient; + daemon: TestPaseoDaemon; + model: string; + workspace: string; + artifacts: string; + close: (passed: boolean) => Promise; +} + +export async function createOpenCodeOmoRealRuntime(): Promise { + const paths = createRuntimePaths(); + const openCodeVersion = + process.env.PASEO_REAL_OPENCODE_VERSION?.trim() || PINNED_OPENCODE_VERSION; + const omoVersion = process.env.PASEO_REAL_OMO_VERSION?.trim() || PINNED_OMO_VERSION; + const openRouterApiKey = process.env.OPENROUTER_API_KEY?.trim() || null; + const model = resolveModel(openRouterApiKey); + const secrets = collectEnvironmentSecrets(openRouterApiKey); + const runtimeEnv = buildRuntimeEnv(paths, openRouterApiKey); + const npmCli = process.env.npm_execpath; + if (!npmCli || !existsSync(npmCli)) { + throw new Error( + `The targeted npm script must provide npm_execpath. Retained real-test artifacts: ${paths.artifacts}`, + ); + } + + await runCommand({ + command: process.execPath, + args: [ + npmCli, + "install", + "--no-save", + "--no-package-lock", + "--prefix", + paths.runtime, + "--cache", + paths.npmCache, + `opencode-ai@${openCodeVersion}`, + `oh-my-openagent@${omoVersion}`, + ], + cwd: paths.root, + env: runtimeEnv, + artifactName: "npm-install.log", + artifacts: paths.artifacts, + secrets, + }); + + const executableSuffix = process.platform === "win32" ? ".cmd" : ""; + const openCodeCommand = path.join( + paths.runtime, + "node_modules", + ".bin", + `opencode${executableSuffix}`, + ); + const omoCommand = path.join(paths.runtime, "node_modules", ".bin", `omo${executableSuffix}`); + assertFixtureExecutable(openCodeCommand, `opencode-ai@${openCodeVersion}`); + assertFixtureExecutable(omoCommand, `oh-my-openagent@${omoVersion}`); + + await runCommand({ + command: omoCommand, + args: [ + "install", + "--no-tui", + "--platform=opencode", + "--claude=no", + "--openai=no", + "--gemini=no", + "--copilot=no", + "--opencode-zen=yes", + "--zai-coding-plan=no", + "--opencode-go=no", + "--kimi-for-coding=no", + ], + cwd: paths.workspace, + env: runtimeEnv, + artifactName: "omo-install.log", + artifacts: paths.artifacts, + secrets, + }); + pinOpenCodePlugin(paths.xdgConfig, omoVersion); + writeOmoModelConfig(paths.home, model); + + await runCommand({ + command: openCodeCommand, + args: ["agent", "list"], + cwd: paths.workspace, + env: runtimeEnv, + artifactName: "plugin-catalog-probe.log", + artifacts: paths.artifacts, + secrets, + }); + + if (model === NO_AUTH_MODEL) { + await runCommand({ + command: openCodeCommand, + args: ["run", "--model", model, "Reply with exactly: PASEO_BIG_PICKLE_PROBE_OK"], + cwd: paths.workspace, + env: runtimeEnv, + artifactName: "big-pickle-probe.log", + artifacts: paths.artifacts, + secrets, + requiredOutput: "PASEO_BIG_PICKLE_PROBE_OK", + }); + } + + const previousPaseoHome = process.env.PASEO_HOME; + let traceDestination: ReturnType | null = null; + let closeTrace: (() => void) | null = null; + let serverManager: OpenCodeServerManager | null = null; + let daemon: TestPaseoDaemon | null = null; + let client: DaemonClient | null = null; + try { + process.env.PASEO_HOME = path.join(paths.paseoHomeRoot, ".paseo"); + traceDestination = pino.destination({ + dest: path.join(paths.artifacts, "daemon.log"), + sync: true, + }); + const redactingTrace = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + callback(null, redactSecrets(chunk.toString(), secrets)); + }, + }); + redactingTrace.pipe(traceDestination, { end: false }); + const logger = pino({ level: "trace" }, redactingTrace); + closeTrace = createTraceCloser(redactingTrace, traceDestination); + const runtimeSettings = { + command: { mode: "replace", argv: [openCodeCommand] }, + env: runtimeEnv, + } as const; + serverManager = new OpenCodeServerManager({ + logger, + runtimeSettings, + baseEnv: runtimeEnv, + resolveHomeDir: () => paths.home, + }); + const openCodeClient = new OpenCodeAgentClient(logger, runtimeSettings, { serverManager }); + daemon = await createTestPaseoDaemon({ + agentClients: { opencode: openCodeClient }, + logger, + paseoHomeRoot: paths.paseoHomeRoot, + staticDir: path.join(paths.root, "static"), + cleanup: false, + }); + client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "opencode-omo-real" } }); + + return { + client, + daemon, + model, + workspace: paths.workspace, + artifacts: paths.artifacts, + close: async (passed) => { + try { + await client.close().catch(() => undefined); + await daemon.close().catch(() => undefined); + await serverManager.shutdown().catch(() => undefined); + closeTrace(); + if (passed) { + rmSync(paths.root, { recursive: true, force: true }); + } + } finally { + restoreEnvironment("PASEO_HOME", previousPaseoHome); + } + }, + }; + } catch (error) { + try { + await client?.close().catch(() => undefined); + await daemon?.close().catch(() => undefined); + await serverManager?.shutdown().catch(() => undefined); + if (closeTrace) { + closeTrace(); + } else { + traceDestination?.end(); + } + } finally { + restoreEnvironment("PASEO_HOME", previousPaseoHome); + } + throw withArtifactLocation(error, paths.artifacts); + } +} + +function createRuntimePaths(): RuntimePaths { + const root = mkdtempSync(path.join(tmpdir(), "paseo-real-opencode-omo-")); + const paths: RuntimePaths = { + root, + home: path.join(root, "home"), + paseoHomeRoot: path.join(root, "paseo-home"), + xdgConfig: path.join(root, "xdg", "config"), + xdgData: path.join(root, "xdg", "data"), + xdgCache: path.join(root, "xdg", "cache"), + temporary: path.join(root, "tmp"), + npmCache: path.join(root, "npm-cache"), + runtime: path.join(root, "runtime"), + artifacts: path.join(root, "artifacts"), + workspace: path.join(root, "workspace"), + }; + for (const directory of Object.values(paths)) { + mkdirSync(directory, { recursive: true }); + } + if (process.platform === "win32") { + const windowsHome = resolveWindowsHomeEnv(paths.home, paths.temporary); + mkdirSync(windowsHome.APPDATA, { recursive: true }); + mkdirSync(windowsHome.LOCALAPPDATA, { recursive: true }); + } + return paths; +} + +function resolveModel(openRouterApiKey: string | null): string { + const explicitModel = process.env.PASEO_REAL_OPENCODE_MODEL?.trim(); + if (explicitModel) { + return explicitModel; + } + return openRouterApiKey ? DEFAULT_OPENROUTER_MODEL : NO_AUTH_MODEL; +} + +function buildRuntimeEnv(paths: RuntimePaths, openRouterApiKey: string | null): NodeJS.ProcessEnv { + return { + LANG: process.env.LANG, + LC_ALL: process.env.LC_ALL, + TZ: process.env.TZ, + SystemRoot: process.env.SystemRoot, + COMSPEC: process.env.COMSPEC, + PATHEXT: process.env.PATHEXT, + NODE_EXTRA_CA_CERTS: process.env.NODE_EXTRA_CA_CERTS, + SSL_CERT_FILE: process.env.SSL_CERT_FILE, + SSL_CERT_DIR: process.env.SSL_CERT_DIR, + HOME: paths.home, + ...(process.platform === "win32" ? resolveWindowsHomeEnv(paths.home, paths.temporary) : {}), + PASEO_HOME: path.join(paths.paseoHomeRoot, ".paseo"), + XDG_CONFIG_HOME: paths.xdgConfig, + XDG_DATA_HOME: paths.xdgData, + XDG_CACHE_HOME: paths.xdgCache, + TMPDIR: paths.temporary, + npm_config_cache: paths.npmCache, + PATH: [path.join(paths.runtime, "node_modules", ".bin"), process.env.PATH] + .filter((entry): entry is string => Boolean(entry)) + .join(path.delimiter), + OPENCODE_DISABLE_AUTO_UPDATE: "1", + CI: "1", + FORCE_COLOR: "", + ...(openRouterApiKey ? { OPENROUTER_API_KEY: openRouterApiKey } : {}), + }; +} + +export function resolveWindowsHomeEnv( + home: string, + temporary: string, +): { + USERPROFILE: string; + HOMEDRIVE: string; + HOMEPATH: string; + APPDATA: string; + LOCALAPPDATA: string; + TEMP: string; + TMP: string; +} { + const root = path.win32.parse(home).root; + const relativeHome = path.win32.relative(root, home); + return { + USERPROFILE: home, + HOMEDRIVE: root.replace(/[\\/]$/, ""), + HOMEPATH: `\\${relativeHome}`, + APPDATA: path.win32.join(home, "AppData", "Roaming"), + LOCALAPPDATA: path.win32.join(home, "AppData", "Local"), + TEMP: temporary, + TMP: temporary, + }; +} + +async function runCommand(input: CommandInput): Promise { + const launch = resolveCommandLaunch(input.command, input.args); + const child = spawn(launch.command, launch.args, { + cwd: input.cwd, + env: input.env, + detached: process.platform !== "win32", + shell: launch.shell, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk)); + + let timeout: NodeJS.Timeout | null = null; + const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }, + ); + const timedOut = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`Command timed out: ${input.artifactName}`)), + COMMAND_TIMEOUT_MS, + ); + }); + + let result: { code: number | null; signal: NodeJS.Signals | null }; + try { + result = await Promise.race([exit, timedOut]); + } catch (error) { + await stopChild(child); + writeCommandArtifact(input, stdout, stderr); + throw withArtifactLocation(error, input.artifacts); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + + const output = writeCommandArtifact(input, stdout, stderr); + if (result.code !== 0) { + throw new Error( + `Command failed (${result.code ?? result.signal ?? "unknown"}): ${input.artifactName}. Retained real-test artifacts: ${input.artifacts}`, + ); + } + if (input.requiredOutput && !output.includes(input.requiredOutput)) { + throw new Error( + `Command did not produce ${input.requiredOutput}: ${input.artifactName}. Retained real-test artifacts: ${input.artifacts}`, + ); + } +} + +export function resolveCommandLaunch( + command: string, + args: string[], + platform: NodeJS.Platform = process.platform, +): { command: string; args: string[]; shell: boolean } { + if (platform === "win32" && path.extname(command).toLowerCase() === ".cmd") { + return { + command: process.env.COMSPEC || "cmd.exe", + args: [ + "/d", + "/s", + "/c", + [ + quoteWindowsCommandArg(command, true), + ...args.map((arg) => quoteWindowsCommandArg(arg)), + ].join(" "), + ], + shell: false, + }; + } + return { + command, + args, + shell: false, + }; +} + +function quoteWindowsCommandArg(value: string, force = false): string { + if (!force && value.length > 0 && !/[\s"&|<>^()]/.test(value)) { + return value; + } + return `"${value.replaceAll('"', '""')}"`; +} + +function writeCommandArtifact(input: CommandInput, stdout: Buffer[], stderr: Buffer[]): string { + const combined = [Buffer.concat(stdout).toString(), Buffer.concat(stderr).toString()] + .filter((text) => text.length > 0) + .join("\n"); + writeFileSync( + path.join(input.artifacts, input.artifactName), + redactSecrets(combined, input.secrets), + ); + return combined; +} + +function redactSecrets(text: string, secrets: string[]): string { + return secrets.reduce((redacted, secret) => redacted.split(secret).join("[REDACTED]"), text); +} + +function collectEnvironmentSecrets(openRouterApiKey: string | null): string[] { + const secrets = Object.entries(process.env) + .filter(([name, value]) => /(?:AUTH|KEY|PASSWORD|SECRET|TOKEN)/i.test(name) && value) + .map(([, value]) => value) + .filter((value): value is string => typeof value === "string" && value.length >= 8); + if (openRouterApiKey) { + secrets.push(openRouterApiKey); + } + return [...new Set(secrets)]; +} + +function createTraceCloser( + trace: Transform, + destination: ReturnType, +): () => void { + let closed = false; + return () => { + if (closed) { + return; + } + closed = true; + trace.end(); + destination.flushSync(); + destination.end(); + }; +} + +async function stopChild(child: ChildProcess): Promise { + await terminateWithTreeKill(child, { + gracefulTimeoutMs: 2_000, + forceTimeoutMs: 2_000, + }); +} + +function assertFixtureExecutable(filePath: string, packageName: string): void { + if (!existsSync(filePath)) { + throw new Error(`Fixture install did not provide ${packageName} at ${filePath}`); + } +} + +function pinOpenCodePlugin(xdgConfig: string, omoVersion: string): void { + const configDirectory = path.join(xdgConfig, "opencode"); + const jsonPath = path.join(configDirectory, "opencode.json"); + mkdirSync(configDirectory, { recursive: true }); + writeFileSync( + jsonPath, + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [`oh-my-openagent@${omoVersion}`], + }, + null, + 2, + ), + ); +} + +function writeOmoModelConfig(home: string, model: string): void { + const configDirectory = path.join(home, ".omo"); + mkdirSync(configDirectory, { recursive: true }); + writeFileSync( + path.join(configDirectory, "omo.jsonc"), + JSON.stringify( + { + "[opencode]": { + $schema: + "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json", + agents: { + sisyphus: { model, fallback_models: [{ model }] }, + "sisyphus-junior": { model, fallback_models: [{ model }] }, + }, + categories: { + quick: { model, fallback_models: [{ model }] }, + }, + opencode: { + background_task: { + providerConcurrency: { [model.split("/")[0] ?? "opencode"]: 2 }, + modelConcurrency: { [model]: 2 }, + }, + }, + }, + }, + null, + 2, + ), + ); +} + +function restoreEnvironment(name: string, previous: string | undefined): void { + if (previous === undefined) { + delete process.env[name]; + return; + } + process.env[name] = previous; +} + +export function withArtifactLocation(error: unknown, artifacts: string): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error(`${message}. Retained real-test artifacts: ${artifacts}`, { cause: error }); +} From 91da0bac4dbc4c939bb764255abe0ccaf9389cb1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 12:28:32 +0200 Subject: [PATCH 26/46] Choose which orchestration skills get installed (#2680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(settings): choose which orchestration skills to install The skills row installed the whole bundle or nothing. A settings control next to it now opens a sheet where you keep every bundled skill or pick a subset, and saving converges ~/.agents, ~/.claude, and ~/.codex to it. "All skills" reads the bundle directory instead of a hardcoded catalog, so a skill added in a later release installs itself without another code change. Retired bundle names stay in a separate cleanup list so old copies on disk are still removed but never become selectable. The selection persists under Electron user data, separate from install state, so uninstalling does not erase the choice — a later install brings back exactly the chosen set. Installs with no saved selection read as "all", which is what they already had. * Restore skill selection after failed save * fix(settings): don't save a skill selection that never reached disk A failed save wrote the preference, converged, then wrote the old preference back. That leaves a window where a selection the user was told did not save is the one on disk, and a crash inside it makes the window permanent. Converge first and persist only on success — nothing to roll back, and a failed save now leaves disk and preference exactly as Cancel would. Resolving the bundle path when the command handlers are wired also broke daemon-manager's tests: the packaged branch reads process.resourcesPath, which is undefined outside Electron. Resolve targets per command instead — registering handlers should not touch the environment. * fix(settings): make skills convergence single-writer and confirm removals Startup auto-update and the settings commands were separate writers over the same skill directories. Either could apply a plan computed from a selection the other had already replaced — narrow the selection while startup is repairing drift and the repair puts the removed skill back. There is now one controller that owns scan, apply, and persist, and every entry point queues through it, so concurrent callers see a consistent result instead of an interleaved one. Save also had no recovery once it had converged: if the selection could not be committed afterwards, disk was changed while the preference still said the old thing, and the next startup silently undid the change. Save now puts the managed directories back to what the committed selection describes before it reports the failure, so a failed Save lands where Cancel would. Deselecting a skill deletes its directory in all three agent homes, including files the user put inside it. That is now confirmed like every other destructive action, naming the skills and the directories. Adding skills or saving an unchanged selection still saves without a prompt, and a skill the host has not installed yet deletes nothing so it does not prompt either. * fix(settings): confirm removal for skills installed in only some agent homes The removal confirmation read an `add` op as "not on disk yet". An `add` op means "missing from at least one target", so a skill sitting in ~/.claude but not the other two looked absent, and deselecting it deleted that directory — with anything the user had put inside it — without asking. The status snapshot now carries the installed skill names directly, taken from union presence across the three agent homes, and the confirmation reads that. `ops` keeps its one meaning. A host that predates the field cannot say what is on disk, so the app bridge assumes the saved selection is installed. Asking about a directory that turns out to be absent is harmless; the reverse is not. * fix(settings): warn about every skill directory a save deletes The confirmation asked "was this selected before and is it selected now", which is a different question from the one the host answers. The host converges to the desired set and deletes every managed directory outside it, so a skill on disk that the saved preference never mentioned — reinstalled by hand, restored by a sync, left behind by drift — was deleted with no warning, user files included. The predicate now mirrors the host rule: everything installed that the draft will not keep, regardless of what was saved before. It moved out of the sheet into its own module so the rule has one owner and can be tested directly. Saving with nothing to delete still does not prompt. * fix(settings): a failed save keeps files the user added inside a skill Save converges before it commits, and convergence deletes whole skill directories. When the commit then failed, the recovery re-installed the committed selection — which rebuilds the bundled files and nothing else, so notes, hooks, and anything else the user had put inside a deselected skill were gone for good. Cancel does not do that, and a failed save is supposed to land where Cancel would. Save now runs as a transaction. Before converging it stages a copy of every managed skill directory across the three agent homes, next to the tree so the copy stays on one filesystem. Committing drops the staging; failing anywhere puts each directory back exactly as it was, deletes the ones the candidate had created, and then drops the staging. Staging left by a process that died mid-save is cleaned up by the next one. The controller only sees begin/commit/rollback; the filesystem work lives in skills-transaction.ts. * fix(settings): make skill removal confirmation authoritative * fix(settings): verify skill convergence before commit * fix(settings): preserve concurrent skill edits on rollback * fix(settings): preserve staged skill metadata * fix(settings): stage skill deletions atomically * fix(settings): stage removals per filesystem * fix(settings): restore staged skill symlinks * fix(settings): canonicalize skill transaction roots * fix(settings): keep automatic skill updates non-destructive * fix(settings): report committed skill saves accurately * fix(settings): keep skill updates non-destructive * fix(settings): route skill removals through selection * fix(settings): close skill selection edge cases * fix(settings): preserve skill data during maintenance * fix(settings): hide unavailable skill install * fix(desktop): keep install policy at command boundary * fix(desktop): restore staged skill directories atomically * test(app): retry transient skills sandbox cleanup * fix(desktop): recover externally deleted skill stages * fix(desktop): preserve conflicting skill rollback data * fix(desktop): generalize skill rollback conflicts * fix(desktop): preserve skill rollback collisions * fix(desktop): recover quarantined skill backups * fix(app): inset adaptive sheet content on compact The compact Choose skills sheet rendered edge to edge: the All skills card touched both screen edges, the section label was glued to it, and the skill list ran to the edges too. Desktop was correct. AdaptiveModalSheet expressed its content inset through BottomSheetScrollView.contentContainerStyle. That is a library prop, not the style prop Unistyles registers, and gorhom's scroller was never rewritten by the Babel plugin, so on web the emitted class was never applied and the padding and gap resolved to nothing. The desktop card uses a real ScrollView, which Unistyles does track, which is why only compact was affected. The inset now lives on a View the sheet owns, rendered by every presentation, so one definition covers compact and desktop and a scroller only ever gets theme-free layout. The caller-facing prop is contentStyle, since it no longer lands on a scroll content container. Choose skills drops its ad-hoc label for SettingsSection, so the label carries the app's section rhythm instead of sitting equidistant between two cards. Co-Authored-By: Claude Opus 5 * fix(desktop): prevent skill rollback data loss Preserve externally replaced directory trees during recovery and reject managed child symlinks before synchronization can write outside a skill directory. --------- Co-authored-by: Claude Opus 5 --- docs/design.md | 2 +- docs/unistyles.md | 9 +- .../e2e/adaptive-sheet-content-inset.spec.ts | 101 ++ packages/app/e2e/helpers/sheet-layout.ts | 68 + packages/app/e2e/helpers/skills-selection.ts | 410 ++++++ packages/app/e2e/skills-selection.spec.ts | 333 +++++ .../src/components/adaptive-modal-sheet.tsx | 79 +- .../composer/agent-controls/model-sheet.tsx | 2 +- .../components/integrations-section.tsx | 80 +- .../components/skill-selection-sheet.tsx | 288 ++++ .../app/src/desktop/daemon/desktop-daemon.ts | 86 +- .../desktop/daemon/skills-snapshot.test.ts | 131 ++ .../app/src/desktop/daemon/skills-snapshot.ts | 113 ++ .../desktop/hooks/use-install-status.test.tsx | 15 +- .../src/desktop/hooks/use-install-status.ts | 57 +- packages/app/src/i18n/resources/ar.ts | 13 + packages/app/src/i18n/resources/en.ts | 13 + packages/app/src/i18n/resources/es.ts | 14 + packages/app/src/i18n/resources/fr.ts | 15 + packages/app/src/i18n/resources/ja.ts | 14 + packages/app/src/i18n/resources/pt-BR.ts | 14 + packages/app/src/i18n/resources/ru.ts | 13 + packages/app/src/i18n/resources/zh-CN.ts | 13 + packages/app/src/styles/settings.ts | 7 - packages/desktop/src/daemon/daemon-manager.ts | 12 +- .../desktop/src/integrations/skills/index.ts | 58 +- .../integrations/skills/operations.test.ts | 282 +++- .../src/integrations/skills/operations.ts | 164 ++- .../skills/selection-store.test.ts | 150 ++ .../integrations/skills/selection-store.ts | 101 ++ .../integrations/skills/skills-commands.ts | 16 + .../skills/skills-controller.test.ts | 1204 +++++++++++++++++ .../integrations/skills/skills-controller.ts | 160 +++ .../integrations/skills/skills-transaction.ts | 607 +++++++++ .../src/integrations/skills/sync.test.ts | 33 + .../desktop/src/integrations/skills/sync.ts | 31 + 36 files changed, 4444 insertions(+), 264 deletions(-) create mode 100644 packages/app/e2e/adaptive-sheet-content-inset.spec.ts create mode 100644 packages/app/e2e/helpers/sheet-layout.ts create mode 100644 packages/app/e2e/helpers/skills-selection.ts create mode 100644 packages/app/e2e/skills-selection.spec.ts create mode 100644 packages/app/src/desktop/components/skill-selection-sheet.tsx create mode 100644 packages/app/src/desktop/daemon/skills-snapshot.test.ts create mode 100644 packages/app/src/desktop/daemon/skills-snapshot.ts create mode 100644 packages/desktop/src/integrations/skills/selection-store.test.ts create mode 100644 packages/desktop/src/integrations/skills/selection-store.ts create mode 100644 packages/desktop/src/integrations/skills/skills-commands.ts create mode 100644 packages/desktop/src/integrations/skills/skills-controller.test.ts create mode 100644 packages/desktop/src/integrations/skills/skills-controller.ts create mode 100644 packages/desktop/src/integrations/skills/skills-transaction.ts diff --git a/docs/design.md b/docs/design.md index 0289894264..06316bea31 100644 --- a/docs/design.md +++ b/docs/design.md @@ -98,7 +98,7 @@ Five primitives. The pick is determined by option count, the need to search, and `` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these. -`` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container. +`` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container. `confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work. diff --git a/docs/unistyles.md b/docs/unistyles.md index 619ceed3b8..7d54a3b4ee 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -129,7 +129,12 @@ This mistake once produced tens of thousands of warnings from retained sidebar r ## Main Gotcha: `contentContainerStyle` -`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section. +`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the `style` prop Unistyles registers. How badly that bites depends on who owns the component: + +- **A remapped React Native `ScrollView`** still picks the style up — the Babel plugin rewrites the import and the factory tracks `contentContainerStyle` alongside `style`. The failure mode here is staleness: on first mount it can paint with the theme that was active at mount time and never repaint. The upstream tutorial calls this out in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section. +- **A third-party scroller** — `BottomSheetScrollView`, `FlashList`, anything Unistyles never rewrote — drops the style entirely on web. Nothing throws and the value looks right in JS, but the class Unistyles emitted is never applied, so the padding, gap, or background is simply absent. `AdaptiveModalSheet` shipped its compact sheet content flush to the screen edges for exactly this reason while the desktop card, which uses a real `ScrollView`, looked correct. + +The second case is why the wrapper-`View` fix below is not optional styling advice. A themed inset belongs on a `View` you own; a third-party scroller only gets theme-free layout. Avoid this pattern when the style depends on the theme: @@ -144,7 +149,7 @@ const styles = StyleSheet.create((theme) => ({ })); ``` -On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons. +If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy reports the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons. This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`. diff --git a/packages/app/e2e/adaptive-sheet-content-inset.spec.ts b/packages/app/e2e/adaptive-sheet-content-inset.spec.ts new file mode 100644 index 0000000000..1be86f2dfc --- /dev/null +++ b/packages/app/e2e/adaptive-sheet-content-inset.spec.ts @@ -0,0 +1,101 @@ +import { expect, test, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + readRails, + readSheetHeaderRails, + readVerticalGap, + waitForSettledPosition, +} from "./helpers/sheet-layout"; +import { + openCompactSkillsIntegrations, + openSkillSelection, + openSkillsIntegrations, + startSkillsSandbox, + type SkillsSandbox, +} from "./helpers/skills-selection"; + +const COMPACT_VIEWPORT = { width: 390, height: 844 }; +const DESKTOP_VIEWPORT = { width: 1280, height: 900 }; + +const sandboxes: SkillsSandbox[] = []; + +test.afterEach(async () => { + for (const sandbox of sandboxes.splice(0)) { + await sandbox.cleanup(); + } +}); + +async function openChooseSkillsOnPhone(page: Page): Promise { + sandboxes.push(await startSkillsSandbox(page)); + await gotoAppShell(page); + await openCompactSkillsIntegrations(page); + await openSkillSelection(page); + await waitForSettledPosition(page.getByTestId("skill-selection-sheet")); +} + +async function openChooseSkillsOnDesktop(page: Page): Promise { + sandboxes.push(await startSkillsSandbox(page)); + await gotoAppShell(page); + await openSkillsIntegrations(page); + await openSkillSelection(page); +} + +function sheetHeaderRails(page: Page) { + return readSheetHeaderRails(page, "skill-selection-sheet", "Choose skills"); +} + +function allSkillsCard(page: Page) { + return page.getByTestId("skill-selection-all-card"); +} + +function skillListCard(page: Page) { + return page.getByTestId("skill-selection-list-card"); +} + +function bundledSkillsLabel(page: Page) { + return page.getByText("Bundled skills", { exact: true }); +} + +test.describe("Choose skills sheet on a compact form factor", () => { + test.use({ viewport: COMPACT_VIEWPORT }); + + test("keeps its cards on the sheet header rails", async ({ page }) => { + await openChooseSkillsOnPhone(page); + + const header = await sheetHeaderRails(page); + + expect(await readRails(allSkillsCard(page))).toEqual(header); + expect(await readRails(skillListCard(page))).toEqual(header); + }); + + test("gives the Bundled skills label more room above it than below", async ({ page }) => { + await openChooseSkillsOnPhone(page); + + expect({ + aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)), + belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)), + }).toEqual({ aboveLabel: 16, belowLabel: 12 }); + }); +}); + +test.describe("Choose skills sheet on a desktop form factor", () => { + test.use({ viewport: DESKTOP_VIEWPORT }); + + test("keeps its cards on the sheet header rails", async ({ page }) => { + await openChooseSkillsOnDesktop(page); + + const header = await sheetHeaderRails(page); + + expect(await readRails(allSkillsCard(page))).toEqual(header); + expect(await readRails(skillListCard(page))).toEqual(header); + }); + + test("gives the Bundled skills label more room above it than below", async ({ page }) => { + await openChooseSkillsOnDesktop(page); + + expect({ + aboveLabel: await readVerticalGap(allSkillsCard(page), bundledSkillsLabel(page)), + belowLabel: await readVerticalGap(bundledSkillsLabel(page), skillListCard(page)), + }).toEqual({ aboveLabel: 16, belowLabel: 12 }); + }); +}); diff --git a/packages/app/e2e/helpers/sheet-layout.ts b/packages/app/e2e/helpers/sheet-layout.ts new file mode 100644 index 0000000000..08e5bb25c7 --- /dev/null +++ b/packages/app/e2e/helpers/sheet-layout.ts @@ -0,0 +1,68 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +export interface Rails { + left: number; + right: number; +} + +interface Box { + x: number; + y: number; + width: number; + height: number; +} + +async function readBox(locator: Locator): Promise { + const box = await locator.boundingBox(); + expect(box, "Expected the element to be laid out").not.toBeNull(); + return box as Box; +} + +/** Leading and trailing edges of an element, in whole pixels. */ +export async function readRails(locator: Locator): Promise { + const box = await readBox(locator); + return { left: Math.round(box.x), right: Math.round(box.x + box.width) }; +} + +/** + * The two rails an AdaptiveModalSheet header indents to: the title's leading + * glyph and the close button's trailing edge. Sheet content sits on the same + * two rails, in every presentation. + */ +export async function readSheetHeaderRails( + page: Page, + sheetTestID: string, + title: string, +): Promise { + const header = page.getByTestId(sheetTestID); + const [titleRails, closeRails] = await Promise.all([ + readRails(header.getByText(title, { exact: true })), + readRails(header.getByRole("button", { name: "Close" })), + ]); + return { left: titleRails.left, right: closeRails.right }; +} + +/** Vertical space between the bottom of one element and the top of the next. */ +export async function readVerticalGap(upper: Locator, lower: Locator): Promise { + const [above, below] = await Promise.all([readBox(upper), readBox(lower)]); + return Math.round(below.y - (above.y + above.height)); +} + +/** + * A compact sheet slides up into place, so anything measured across two reads + * has to wait for the surface to stop moving first. + */ +export async function waitForSettledPosition(locator: Locator): Promise { + let previousTop: number | null = null; + await expect + .poll( + async () => { + const { y } = await readBox(locator); + const settled = previousTop === y; + previousTop = y; + return settled; + }, + { message: "Expected the sheet to stop moving" }, + ) + .toBe(true); +} diff --git a/packages/app/e2e/helpers/skills-selection.ts b/packages/app/e2e/helpers/skills-selection.ts new file mode 100644 index 0000000000..873fca4791 --- /dev/null +++ b/packages/app/e2e/helpers/skills-selection.ts @@ -0,0 +1,410 @@ +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, type Page } from "@playwright/test"; +import { buildOpenProjectRoute } from "@/utils/host-routes"; +import { openSettings } from "./app"; +import { injectDesktopBridge } from "./desktop-updates"; +import { getServerId } from "./server-id"; +import { openCompactSettings, openSettingsSection } from "./settings"; +// The desktop skills module is the real command surface: the same handlers the +// Electron main process registers, running against a temp bundle and temp user +// data. Nothing about persistence or convergence is simulated in the browser. +import type { + SkillSelection, + SkillTargets, +} from "../../../desktop/src/integrations/skills/operations.js"; +import { createSkillSelectionStore } from "../../../desktop/src/integrations/skills/selection-store.js"; +import { createSkillsCommandHandlers } from "../../../desktop/src/integrations/skills/skills-commands.js"; +import { createSkillsController } from "../../../desktop/src/integrations/skills/skills-controller.js"; + +const SKILL_COMMANDS = [ + "get_skills_status", + "install_skills", + "update_skills", + "uninstall_skills", + "save_skills_selection", +] as const; + +declare global { + interface Window { + __paseoSkillsInvoke?: ( + command: string, + args: Record | null, + ) => Promise; + } +} + +export interface SkillsSandbox { + targets: SkillTargets; + userDataPath: string; + bundledSkills: string[]; + /** Writes a managed skill directory the way an external tool would. */ + placeSkillOnDisk: (skill: string, target: SkillTargetName) => Promise; + /** Commits a selection through the real controller, as another window would. */ + commitSelectionExternally: ( + selection: SkillSelection, + confirmedRemovals?: readonly string[], + ) => Promise; + /** Waits until a held save reaches the desktop command boundary. */ + waitForHeldSave: () => Promise; + /** Releases a save held at the desktop command boundary. */ + releaseHeldSave: () => void; + /** Test-only command-boundary gate used to keep the renderer in its pending state. */ + holdSaveResponse?: () => Promise; + cleanup: () => Promise; +} + +export interface SkillsSandboxOptions { + bundledSkills?: string[]; + /** + * Puts a regular file where the `.agents` skills tree has to go, so the real + * convergence fails with ENOTDIR instead of a stubbed rejection. + */ + blockAgentsDir?: boolean; + /** Converge and commit this selection before the app loads, through the real controller. */ + installed?: SkillSelection; + /** + * Leaves a skill installed in one agent home only, the way a partial install + * or a hand-deleted directory leaves it. + */ + keepOnlyIn?: { skill: string; target: SkillTargetName }; + /** + * Writes a skill directory straight into one agent home after the selection is + * committed, the way a manual reinstall or an external sync leaves a directory + * the saved preference does not mention. + */ + placeOnDisk?: { skill: string; target: SkillTargetName; files: Record }; + /** Holds the save response so dismissal behavior can be asserted while pending. */ + holdSave?: boolean; +} + +export type SkillTargetName = "agents" | "claude" | "codex"; + +function targetDir(targets: SkillTargets, target: SkillTargetName): string { + if (target === "agents") return targets.agentsDir; + if (target === "claude") return targets.claudeDir; + return targets.codexDir; +} + +export async function createSkillsSandbox( + options: SkillsSandboxOptions = {}, +): Promise { + let releaseHeldSave: (() => void) | null = null; + let markSaveHeld: (() => void) | null = null; + const heldSave = new Promise((resolve) => { + markSaveHeld = resolve; + }); + const saveRelease = new Promise((resolve) => { + releaseHeldSave = resolve; + }); + const bundledSkills = options.bundledSkills ?? ["paseo", "paseo-advisor", "paseo-loop"]; + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-e2e-skills-")); + const targets: SkillTargets = { + sourceDir: path.join(root, "bundle"), + agentsDir: path.join(root, "home", ".agents", "skills"), + claudeDir: path.join(root, "home", ".claude", "skills"), + codexDir: path.join(root, "home", ".codex", "skills"), + }; + + for (const name of bundledSkills) { + await mkdir(path.join(targets.sourceDir, name), { recursive: true }); + await writeFile(path.join(targets.sourceDir, name, "SKILL.md"), `# ${name}\n`, "utf8"); + } + + const userDataPath = path.join(root, "user-data"); + if (options.installed) { + await createSkillsController({ + resolveTargets: () => targets, + selectionStore: createSkillSelectionStore({ userDataPath }), + }).save(options.installed); + } + + if (options.keepOnlyIn) { + const kept = targetDir(targets, options.keepOnlyIn.target); + for (const dir of [targets.agentsDir, targets.claudeDir, targets.codexDir]) { + if (dir === kept) continue; + await rm(path.join(dir, options.keepOnlyIn.skill), { recursive: true, force: true }); + } + } + + if (options.placeOnDisk) { + const dir = path.join( + targetDir(targets, options.placeOnDisk.target), + options.placeOnDisk.skill, + ); + for (const [rel, contents] of Object.entries(options.placeOnDisk.files)) { + const file = path.join(dir, rel); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, contents, "utf8"); + } + } + + if (options.blockAgentsDir) { + await mkdir(path.dirname(targets.agentsDir), { recursive: true }); + await writeFile(targets.agentsDir, "not a directory", "utf8"); + } + + return { + targets, + userDataPath, + bundledSkills, + placeSkillOnDisk: async (skill, target) => { + const dir = path.join(targetDir(targets, target), skill); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "SKILL.md"), `# ${skill}\n`, "utf8"); + }, + commitSelectionExternally: async (selection, confirmedRemovals = []) => { + await createSkillsController({ + resolveTargets: () => targets, + selectionStore: createSkillSelectionStore({ userDataPath }), + }).save({ ...selection, confirmedRemovals: [...confirmedRemovals] }); + }, + waitForHeldSave: () => heldSave, + releaseHeldSave: () => releaseHeldSave?.(), + holdSaveResponse: options.holdSave + ? async () => { + markSaveHeld?.(); + await saveRelease; + } + : undefined, + // The save path atomically renames skill directories. On Linux/overlayfs, + // recursive removal can briefly observe a repopulated directory and fail + // with ENOTEMPTY even after the command has completed. Node only retries + // that transient when maxRetries is set. + cleanup: () => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }), + }; +} + +/** Reads the committed selection back through the same store the app writes. */ +export async function expectSavedSkillSelection( + sandbox: SkillsSandbox, + expected: SkillSelection, +): Promise { + const store = createSkillSelectionStore({ userDataPath: sandbox.userDataPath }); + await expect.poll(() => store.get(), { timeout: 15_000 }).toEqual(expected); +} + +/** + * Routes the skills desktop commands to the real handlers in Node, leaving every + * other command on the injected desktop bridge. Call after `injectDesktopBridge` + * and before navigation. + */ +export async function serveRealSkillsCommands(page: Page, sandbox: SkillsSandbox): Promise { + const handlers = createSkillsCommandHandlers({ + controller: createSkillsController({ + resolveTargets: () => sandbox.targets, + selectionStore: createSkillSelectionStore({ userDataPath: sandbox.userDataPath }), + }), + }); + + await page.exposeFunction( + "__paseoSkillsInvoke", + async (command: string, args: Record | null) => { + const handler = handlers[command]; + if (!handler) throw new Error(`Unknown skills command: ${command}`); + const result = await handler(args ?? undefined); + if (command === "save_skills_selection") { + await sandbox.holdSaveResponse?.(); + } + return result; + }, + ); + + await page.addInitScript((commands: readonly string[]) => { + const bridge = (window as unknown as { paseoDesktop?: { invoke: unknown } }).paseoDesktop; + if (!bridge) throw new Error("Desktop bridge must be injected before the skills bridge."); + const skillCommands = new Set(commands); + const inner = bridge.invoke as ( + command: string, + args?: Record, + ) => Promise; + bridge.invoke = (command: string, args?: Record) => + skillCommands.has(command) + ? window.__paseoSkillsInvoke!(command, args ?? null) + : inner(command, args); + }, SKILL_COMMANDS); +} + +/** Real skills commands over a temp bundle, wired to this page's desktop bridge. */ +export async function startSkillsSandbox( + page: Page, + options: SkillsSandboxOptions = {}, +): Promise { + const sandbox = await createSkillsSandbox(options); + await injectDesktopBridge(page, { serverId: getServerId(), confirmShouldAccept: false }); + await serveRealSkillsCommands(page, sandbox); + return sandbox; +} + +export async function openSkillsIntegrations(page: Page): Promise { + await openSettings(page); + await openSettingsSection(page, "integrations"); + await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible(); +} + +/** Settings on a compact form factor stacks list and detail, so it needs the menu first. */ +export async function openCompactSkillsIntegrations(page: Page): Promise { + await openCompactSettings(page, buildOpenProjectRoute()); + await openSettingsSection(page, "integrations"); + await expect(page.getByText("Orchestration skills", { exact: true })).toBeVisible(); +} + +export async function openSkillSelection(page: Page): Promise { + await page.getByRole("button", { name: "Choose skills", exact: true }).click(); + await expectSkillSelectionOpen(page); +} + +export async function expectSkillSelectionOpen(page: Page): Promise { + await expect(page.getByRole("switch", { name: "All skills", exact: true })).toBeVisible(); +} + +export async function expectSkillSelectionClosed(page: Page): Promise { + await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0); +} + +export async function chooseAllSkills(page: Page): Promise { + const allSkills = page.getByRole("switch", { name: "All skills", exact: true }); + await expect(allSkills).not.toBeChecked(); + await allSkills.click(); + await expect(allSkills).toBeChecked(); +} + +export async function chooseCustomSkills(page: Page, skills: string[]): Promise { + const allSkills = page.getByRole("switch", { name: "All skills", exact: true }); + await expect(allSkills).toBeChecked(); + await allSkills.click(); + await expect(allSkills).not.toBeChecked(); + + const keep = new Set(skills); + for (const name of await listSkillChoices(page)) { + if (keep.has(name)) continue; + await page.getByRole("checkbox", { name, exact: true }).click(); + } + await expectSkillChoices(page, skills); +} + +export async function toggleSkill(page: Page, name: string): Promise { + await page.getByRole("checkbox", { name, exact: true }).click(); +} + +export async function saveSkillSelection(page: Page): Promise { + await page.getByRole("button", { name: "Save", exact: true }).click(); +} + +export async function cancelSkillSelection(page: Page): Promise { + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + await expectSkillSelectionClosed(page); +} + +export async function listSkillChoices(page: Page): Promise { + const rows = page.getByRole("checkbox"); + const names = await rows.evaluateAll((elements) => + elements.map((element) => element.getAttribute("aria-label") ?? ""), + ); + return names.filter((name) => name.length > 0); +} + +/** Asserts exactly which skills are checked in the open sheet. */ +export async function expectSkillChoices(page: Page, skills: string[]): Promise { + const expected = new Set(skills); + for (const name of await listSkillChoices(page)) { + const choice = page.getByRole("checkbox", { name, exact: true }); + if (expected.has(name)) { + await expect(choice).toBeChecked(); + continue; + } + await expect(choice).not.toBeChecked(); + } +} + +/** Reopens the saved selection and asserts what the user sees, then closes it. */ +export async function expectSelectedSkills(page: Page, skills: string[]): Promise { + await openSkillSelection(page); + await expect(page.getByRole("switch", { name: "All skills", exact: true })).not.toBeChecked(); + await expectSkillChoices(page, skills); + await cancelSkillSelection(page); +} + +export async function expectAllSkillsSelected(page: Page): Promise { + await openSkillSelection(page); + const allSkills = page.getByRole("switch", { name: "All skills", exact: true }); + await expect(allSkills).toBeChecked(); + await expectSkillChoices(page, await listSkillChoices(page)); + await cancelSkillSelection(page); +} + +export async function expectSaveErrorKeepsSheetOpen(page: Page): Promise { + await expect(page.getByText("Could not save your skill selection.", { exact: true })).toBeVisible( + { timeout: 15_000 }, + ); + await expectSkillSelectionOpen(page); +} + +/** + * The removal warning is the desktop confirm dialog, which has no DOM to query. + * The injected bridge records the call the same way the daemon-management tests + * assert their confirmation copy. + */ +export async function expectRemovalWarning(page: Page, skills: string[]): Promise { + await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 15_000 }); + const call = await page.evaluate(() => window.__capturedDialogCall); + expect(call?.title).toBe("Remove deselected skills?"); + for (const name of skills) { + expect(call?.message).toContain(name); + } + for (const dir of ["~/.agents", "~/.claude", "~/.codex"]) { + expect(call?.message).toContain(dir); + } + expect(call?.message).toContain("Anything you added inside those skill folders is deleted too."); +} + +export async function expectNoRemovalWarning(page: Page): Promise { + expect(await page.evaluate(() => window.__capturedDialogCall)).toBeUndefined(); +} + +export async function expectSkillPresentIn( + sandbox: SkillsSandbox, + target: SkillTargetName, + skill: string, +): Promise { + await expect + .poll(() => readInstalledSkills(targetDir(sandbox.targets, target)), { timeout: 15_000 }) + .toContain(skill); +} + +export async function expectSkillFilePresent( + sandbox: SkillsSandbox, + target: SkillTargetName, + skill: string, + relativePath: string, +): Promise { + const file = path.join(targetDir(sandbox.targets, target), skill, relativePath); + await expect + .poll(() => readFile(file, "utf8").catch(() => null), { timeout: 15_000 }) + .not.toBeNull(); +} + +export async function expectSkillsInstalled( + sandbox: SkillsSandbox, + skills: string[], +): Promise { + const dirs = [ + sandbox.targets.agentsDir, + sandbox.targets.claudeDir, + sandbox.targets.codexDir, + ] as const; + for (const dir of dirs) { + await expect + .poll(async () => readInstalledSkills(dir), { timeout: 15_000 }) + .toEqual([...skills].sort()); + } +} + +async function readInstalledSkills(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} diff --git a/packages/app/e2e/skills-selection.spec.ts b/packages/app/e2e/skills-selection.spec.ts new file mode 100644 index 0000000000..4eb1185d32 --- /dev/null +++ b/packages/app/e2e/skills-selection.spec.ts @@ -0,0 +1,333 @@ +import { test as base } from "./fixtures"; +import { expect } from "@playwright/test"; +import { gotoAppShell } from "./helpers/app"; +import { injectDesktopBridge } from "./helpers/desktop-updates"; +import { getServerId } from "./helpers/server-id"; +import { + cancelSkillSelection, + chooseAllSkills, + chooseCustomSkills, + createSkillsSandbox, + expectAllSkillsSelected, + expectNoRemovalWarning, + expectRemovalWarning, + expectSavedSkillSelection, + expectSaveErrorKeepsSheetOpen, + expectSelectedSkills, + expectSkillChoices, + expectSkillFilePresent, + expectSkillPresentIn, + expectSkillSelectionOpen, + expectSkillsInstalled, + openSkillsIntegrations, + openSkillSelection, + saveSkillSelection, + toggleSkill, + serveRealSkillsCommands, + type SkillsSandbox, + type SkillsSandboxOptions, +} from "./helpers/skills-selection"; + +interface SkillsSandboxSetup extends SkillsSandboxOptions { + /** What the desktop confirm dialog answers when the removal warning fires. */ + confirmRemoval?: boolean; +} + +type StartSkills = (setup?: SkillsSandboxSetup) => Promise; + +// Every skills command runs against the real desktop handlers over a temp bundle +// and temp user data, so the assertions cover persistence and convergence, not a +// browser stand-in for them. +const test = base.extend<{ startSkills: StartSkills }>({ + startSkills: async ({ page }, provide) => { + const sandboxes: SkillsSandbox[] = []; + await provide(async (setup = {}) => { + const { confirmRemoval, ...options } = setup; + const sandbox = await createSkillsSandbox(options); + await injectDesktopBridge(page, { + serverId: getServerId(), + confirmShouldAccept: confirmRemoval ?? false, + }); + await serveRealSkillsCommands(page, sandbox); + sandboxes.push(sandbox); + return sandbox; + }); + for (const sandbox of sandboxes) { + await sandbox.cleanup(); + } + }, +}); + +test.describe("Choosing installed skills", () => { + test("installs only the skills selected in Settings", async ({ page, startSkills }) => { + const skills = await startSkills(); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + await openSkillSelection(page); + await chooseCustomSkills(page, ["paseo", "paseo-loop"]); + await saveSkillSelection(page); + + await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]); + await expectSelectedSkills(page, ["paseo", "paseo-loop"]); + }); + + test("all skills includes every available skill", async ({ page, startSkills }) => { + const skills = await startSkills(); + await gotoAppShell(page); + await openSkillsIntegrations(page); + await openSkillSelection(page); + await chooseCustomSkills(page, ["paseo"]); + await saveSkillSelection(page); + + await openSkillSelection(page); + await chooseAllSkills(page); + await saveSkillSelection(page); + + await expectSkillsInstalled(skills, skills.bundledSkills); + await expectAllSkillsSelected(page); + }); + + test("cancelling leaves the installed skills untouched", async ({ page, startSkills }) => { + const skills = await startSkills(); + await gotoAppShell(page); + await openSkillsIntegrations(page); + await openSkillSelection(page); + await chooseCustomSkills(page, ["paseo"]); + await saveSkillSelection(page); + + await openSkillSelection(page); + await toggleSkill(page, "paseo-loop"); + await cancelSkillSelection(page); + + await expectSkillsInstalled(skills, ["paseo"]); + await expectSelectedSkills(page, ["paseo"]); + }); + + test("a failed save keeps the sheet open with an error", async ({ page, startSkills }) => { + const skills = await startSkills({ blockAgentsDir: true }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + await openSkillSelection(page); + await chooseCustomSkills(page, ["paseo"]); + await saveSkillSelection(page); + + await expectSaveErrorKeepsSheetOpen(page); + await expectSkillsInstalled(skills, []); + }); + + test("an empty selection does not offer an Install action that cannot install anything", async ({ + page, + startSkills, + }) => { + await startSkills({ installed: { mode: "all" }, confirmRemoval: true }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + await openSkillSelection(page); + await chooseCustomSkills(page, []); + await saveSkillSelection(page); + + // The remaining Install action belongs to the command-line integration. + await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1); + await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible(); + }); + + test("a selection containing only retired skills does not offer a no-op Install", async ({ + page, + startSkills, + }) => { + await startSkills({ installed: { mode: "custom", skills: ["retired-skill"] } }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + // The remaining Install action belongs to the command-line integration. + await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1); + await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible(); + }); + + test("an empty bundled catalog does not offer a no-op Install", async ({ page, startSkills }) => { + await startSkills({ bundledSkills: [] }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + // The remaining Install action belongs to the command-line integration. + await expect(page.getByRole("button", { name: "Install", exact: true })).toHaveCount(1); + await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible(); + }); + + test("cannot dismiss the sheet while Save is pending", async ({ page, startSkills }) => { + const skills = await startSkills({ holdSave: true }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + await openSkillSelection(page); + await chooseCustomSkills(page, ["paseo"]); + + await saveSkillSelection(page); + await skills.waitForHeldSave(); + await page.keyboard.press("Escape"); + await expectSkillSelectionOpen(page); + + skills.releaseHeldSave(); + await expect(page.getByRole("switch", { name: "All skills", exact: true })).toHaveCount(0); + }); +}); + +test.describe("Removing an installed skill", () => { + test("delete-only drift stays in Choose skills instead of Update", async ({ + page, + startSkills, + }) => { + await startSkills({ + installed: { mode: "custom", skills: ["paseo"] }, + placeOnDisk: { + skill: "paseo-loop", + target: "claude", + files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" }, + }, + }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + await expect(page.getByText("Update available", { exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Update", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Choose skills", exact: true })).toBeVisible(); + }); + + test("warns before deleting the skill folders", async ({ page, startSkills }) => { + const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + await openSkillSelection(page); + await chooseCustomSkills(page, ["paseo", "paseo-advisor"]); + await saveSkillSelection(page); + + await expectRemovalWarning(page, ["paseo-loop"]); + await expectSkillSelectionOpen(page); + await expectSkillsInstalled(skills, skills.bundledSkills); + await expectSavedSkillSelection(skills, { mode: "all" }); + }); + + test("confirming the warning removes the skill", async ({ page, startSkills }) => { + const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: true }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + await openSkillSelection(page); + await chooseCustomSkills(page, ["paseo", "paseo-advisor"]); + await saveSkillSelection(page); + + await expectRemovalWarning(page, ["paseo-loop"]); + await expectSkillsInstalled(skills, ["paseo", "paseo-advisor"]); + await expectSelectedSkills(page, ["paseo", "paseo-advisor"]); + }); + + test("warns for a skill installed in only one agent directory", async ({ page, startSkills }) => { + const skills = await startSkills({ + installed: { mode: "all" }, + keepOnlyIn: { skill: "paseo-loop", target: "claude" }, + confirmRemoval: false, + }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + await openSkillSelection(page); + await chooseCustomSkills(page, ["paseo", "paseo-advisor"]); + await saveSkillSelection(page); + + await expectRemovalWarning(page, ["paseo-loop"]); + await expectSkillSelectionOpen(page); + await expectSkillPresentIn(skills, "claude", "paseo-loop"); + await expectSavedSkillSelection(skills, { mode: "all" }); + }); + + test("warns for a skill on disk that the saved selection never mentioned", async ({ + page, + startSkills, + }) => { + // The committed preference excludes paseo-loop, but the directory is back — + // a manual reinstall or an external sync. Saving any edit deletes it. + const skills = await startSkills({ + installed: { mode: "custom", skills: ["paseo"] }, + placeOnDisk: { + skill: "paseo-loop", + target: "claude", + files: { "SKILL.md": "# paseo-loop\n", "notes/mine.md": "my notes" }, + }, + confirmRemoval: false, + }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + await openSkillSelection(page); + await toggleSkill(page, "paseo-advisor"); + await saveSkillSelection(page); + + await expectRemovalWarning(page, ["paseo-loop"]); + await expectSkillSelectionOpen(page); + await expectSkillFilePresent(skills, "claude", "paseo-loop", "notes/mine.md"); + await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] }); + }); + + test("warns about a directory that appears after the sheet was opened", async ({ + page, + startSkills, + }) => { + const skills = await startSkills({ + installed: { mode: "custom", skills: ["paseo"] }, + confirmRemoval: false, + }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + await openSkillSelection(page); + + // Nothing to delete when the sheet opened; the host looks again at save time. + await skills.placeSkillOnDisk("paseo-loop", "claude"); + await toggleSkill(page, "paseo-advisor"); + await saveSkillSelection(page); + + await expectRemovalWarning(page, ["paseo-loop"]); + await expectSkillSelectionOpen(page); + await expectSkillPresentIn(skills, "claude", "paseo-loop"); + await expectSavedSkillSelection(skills, { mode: "custom", skills: ["paseo"] }); + }); + + test("keeps draft choices when fresher status arrives while the sheet is open", async ({ + page, + startSkills, + }) => { + const skills = await startSkills({ installed: { mode: "all" }, confirmRemoval: false }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + await openSkillSelection(page); + await chooseCustomSkills(page, ["paseo", "paseo-advisor"]); + + // Another window commits a different selection. Saving picks that up, so the + // sheet is handed a selection it did not start from while an edit is open. + await skills.commitSelectionExternally({ mode: "custom", skills: ["paseo", "paseo-loop"] }, [ + "paseo-advisor", + ]); + await saveSkillSelection(page); + await expectRemovalWarning(page, ["paseo-loop"]); + + await expectSkillChoices(page, ["paseo", "paseo-advisor"]); + }); + + test("adding a skill saves without a warning", async ({ page, startSkills }) => { + const skills = await startSkills({ + installed: { mode: "custom", skills: ["paseo"] }, + confirmRemoval: false, + }); + await gotoAppShell(page); + await openSkillsIntegrations(page); + + await openSkillSelection(page); + await toggleSkill(page, "paseo-loop"); + await saveSkillSelection(page); + + await expectSkillsInstalled(skills, ["paseo", "paseo-loop"]); + await expectNoRemovalWarning(page); + }); +}); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 8d06a21813..90f8718333 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -165,19 +165,17 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, minHeight: 0, }, - desktopContent: { + // The sheet's content inset — one definition for every presentation, always + // applied through . + sheetContent: { padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], gap: theme.spacing[4], + }, + contentGrow: { flexGrow: 1, }, - bottomSheetContent: { - padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], - gap: theme.spacing[4], - }, - bottomSheetStaticContent: { + compactStaticContent: { flex: 1, - padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], - gap: theme.spacing[4], minHeight: 0, }, bottomSheetVisibleContent: { @@ -191,8 +189,6 @@ const styles = StyleSheet.create((theme) => ({ desktopStaticContent: { flexShrink: 1, minHeight: 0, - padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], - gap: theme.spacing[4], }, footer: { paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], @@ -233,6 +229,20 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) { return ; } +/** + * The sheet body, indented to the sheet's content inset. + * + * The inset lives on a real `View` and never on a scroller's + * `contentContainerStyle`: that is a library prop, not the `style` prop + * Unistyles registers, so a themed inset handed to a third-party scroller such + * as `BottomSheetScrollView` silently resolves to nothing on web — which is how + * the compact sheet ended up rendering its cards flush to the screen edges. See + * docs/unistyles.md "Main Gotcha: contentContainerStyle". + */ +function SheetContent({ style, children }: { style: StyleProp; children: ReactNode }) { + return {children}; +} + function BottomSheetVisibleContent({ children }: { children: ReactNode }) { const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } = useBottomSheetInternal(); @@ -372,6 +382,7 @@ export function SheetHeaderView({ {header.actions ? {header.actions} : null} {showCloseButton ? ( ; + /** Layout intent for the sheet body, composed over the sheet's own content inset. */ + contentStyle?: StyleProp; /** Size compact sheet content to the live snap height instead of its largest snap point. */ sizeContentToCurrentSnapPoint?: boolean; } @@ -495,7 +507,7 @@ export function AdaptiveModalSheet({ desktopMaxWidth, scrollable = true, presentation, - contentContainerStyle, + contentStyle, sizeContentToCurrentSnapPoint = false, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); @@ -514,28 +526,26 @@ export function AdaptiveModalSheet({ }), [footer, insets.bottom, isMobile, theme.spacing], ); - const bottomSheetContentStyle = useMemo( - // Gorhom spreads this outer array into StyleSheet.compose, which accepts two arguments on web. + const compactContentStyle = useMemo( () => [ - styles.bottomSheetContent, - [ - contentContainerStyle, - compactSafeAreaPadding.contentPaddingBottom != null - ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } - : null, - ], - ], - [compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle], - ); - const bottomSheetStaticContentStyle = useMemo( - () => [ - styles.bottomSheetStaticContent, - contentContainerStyle, + contentStyle, compactSafeAreaPadding.contentPaddingBottom != null ? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom } : null, ], - [compactSafeAreaPadding.contentPaddingBottom, contentContainerStyle], + [compactSafeAreaPadding.contentPaddingBottom, contentStyle], + ); + const compactStaticContentStyle = useMemo( + () => [styles.compactStaticContent, compactContentStyle], + [compactContentStyle], + ); + const desktopScrollContentStyle = useMemo( + () => [styles.contentGrow, contentStyle], + [contentStyle], + ); + const desktopStaticContentStyle = useMemo( + () => [styles.desktopStaticContent, contentStyle], + [contentStyle], ); const footerStyle = useMemo( () => [ @@ -649,14 +659,13 @@ export function AdaptiveModalSheet({ {scrollable ? ( - {children} + {children} ) : ( - {children} + {children} )} {footer ? {footer} : null} @@ -695,15 +704,15 @@ export function AdaptiveModalSheet({ - {children} + {children} ) : ( - {children} + {children} )} {footer ? {footer} : null} diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index b3ba7f1ee5..a3ea5e022c 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -155,7 +155,7 @@ export function CompactModelSheet({ sizeContentToCurrentSnapPoint={usesBottomSheet} footer={compactFooter} footerContainerStyle={usesBottomSheet ? styles.compactFooterContainer : undefined} - contentContainerStyle={styles.sheetBody} + contentStyle={styles.sheetBody} testID="agent-controls-model-sheet" > skillsStatus?.ops.filter((op) => op.kind !== "delete") ?? [], + [skillsStatus?.ops], + ); useFocusEffect( useCallback(() => { @@ -74,18 +81,17 @@ export function IntegrationsSection() { const handleUpdateSkills = useCallback(async () => { if (isSkillsWorking) return; - const ops = skillsStatus?.ops ?? []; const confirmed = await confirmDialog({ title: t("settings.integrations.skills.updateTitle"), message: - ops.length > 0 - ? formatUpdateMessage(ops, t) + skillMaintenanceOps.length > 0 + ? formatUpdateMessage(skillMaintenanceOps, t) : t("settings.integrations.skills.updateFallback"), confirmLabel: t("settings.integrations.actions.update"), }); if (!confirmed) return; await updateSkills(); - }, [isSkillsWorking, skillsStatus, t, updateSkills]); + }, [isSkillsWorking, skillMaintenanceOps, t, updateSkills]); const handleUninstallSkills = useCallback(async () => { if (isSkillsWorking) return; @@ -99,6 +105,14 @@ export function IntegrationsSection() { await uninstallSkills(); }, [isSkillsWorking, t, uninstallSkills]); + const handleOpenSkillSelection = useCallback(() => { + setIsChoosingSkills(true); + }, []); + + const handleCloseSkillSelection = useCallback(() => { + setIsChoosingSkills(false); + }, []); + const handleOpenCliDocs = useCallback(() => { void openExternalUrl(CLI_DOCS_URL); }, []); @@ -112,6 +126,11 @@ export function IntegrationsSection() { [theme.iconSize.sm, theme.colors.foregroundMuted], ); + const chooseSkillsIcon = useMemo( + () => , + [theme.iconSize.sm, theme.colors.foregroundMuted], + ); + const trailing = useMemo( () => ( @@ -146,7 +165,14 @@ export function IntegrationsSection() { return null; } - const skillsState = skillsStatus?.state ?? null; + const skillsState = + skillsStatus?.state === "drift" && skillMaintenanceOps.length === 0 + ? "up-to-date" + : (skillsStatus?.state ?? null); + const hasSelectedSkills = + (skillsStatus?.selection.mode === "all" && skillsStatus.available.length > 0) || + (skillsStatus?.selection.mode === "custom" && + skillsStatus.selection.skills.some((name) => skillsStatus.available.includes(name))); return ( @@ -193,21 +219,43 @@ export function IntegrationsSection() { : t("settings.integrations.skills.description")} - + + + + + ), + [handleDismiss, handleSave, isSaving, t], + ); + + const allSkills = draft.mode === "all"; + + return ( + + + + + + {t("settings.integrations.skills.chooseAll")} + + + {t("settings.integrations.skills.chooseAllHint")} + + + + + + + {/* The sheet body already spaces its blocks apart, so the section owns + only the gap between its label and the list the label names. */} + + + {available.length === 0 ? ( + + + {t("settings.integrations.skills.chooseEmpty")} + + + ) : ( + available.map((name, index) => ( + 0} + onToggle={handleToggleSkill} + /> + )) + )} + + + + {saveError ? {saveError} : null} + + ); +} + +interface SkillCheckboxRowProps { + name: string; + checked: boolean; + disabled: boolean; + withBorder: boolean; + onToggle: (name: string) => void; +} + +function SkillCheckboxRow({ + name, + checked, + disabled, + withBorder, + onToggle, +}: SkillCheckboxRowProps) { + const handlePress = useCallback(() => onToggle(name), [name, onToggle]); + const accessibilityState = useMemo(() => ({ checked, disabled }), [checked, disabled]); + const rowStyle = useMemo( + () => [ + settingsStyles.row, + withBorder ? settingsStyles.rowBorder : null, + styles.checkboxRow, + disabled ? styles.disabledRow : null, + ], + [disabled, withBorder], + ); + const boxStyle = useMemo( + () => [styles.checkbox, checked ? styles.checkboxChecked : null], + [checked], + ); + + return ( + + + {checked ? : null} + + {name} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + footer: { + flex: 1, + flexDirection: "row", + gap: theme.spacing[3], + }, + footerButton: { + flex: 1, + }, + checkboxRow: { + justifyContent: "flex-start", + gap: theme.spacing[3], + }, + disabledRow: { + opacity: theme.opacity[50], + }, + checkbox: { + width: 22, + height: 22, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + checkboxChecked: { + backgroundColor: theme.colors.accent, + borderColor: theme.colors.accent, + }, +})); diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index e2729f2cbe..a4292128d1 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -1,5 +1,12 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { invokeDesktopCommand } from "@/desktop/electron/invoke"; +import { + parseSkillsSaveResult, + parseSkillsSnapshot, + type SkillSelection, + type SkillsSaveResult, + type SkillsSnapshot, +} from "@/desktop/daemon/skills-snapshot"; export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; export type DesktopDaemonStopReason = @@ -246,67 +253,38 @@ export async function installCli(): Promise { return parseInstallStatus(await invokeDesktopCommand("install_cli")); } -export type SkillsState = "not-installed" | "up-to-date" | "drift"; +export type { + SkillOp, + SkillSelection, + SkillsSaveResult, + SkillsSnapshot, + SkillsState, +} from "@/desktop/daemon/skills-snapshot"; -export type SkillOp = - | { kind: "add"; name: string } - | { kind: "update"; name: string } - | { kind: "delete"; name: string }; - -export interface SkillsStatus { - state: SkillsState; - ops: SkillOp[]; +export async function getSkillsSnapshot(): Promise { + return parseSkillsSnapshot(await invokeDesktopCommand("get_skills_status")); } -function parseSkillsState(value: unknown): SkillsState { - switch (value) { - case "not-installed": - case "up-to-date": - case "drift": - return value; - default: - throw new Error(`Unexpected skills status state: ${String(value)}`); - } +export async function installSkills(): Promise { + return parseSkillsSnapshot(await invokeDesktopCommand("install_skills")); } -function parseSkillOp(raw: unknown): SkillOp { - if (!isRecord(raw)) { - throw new Error("Unexpected skill op response."); - } - const name = toStringOrNull(raw.name); - if (!name) throw new Error("Skill op missing name."); - switch (raw.kind) { - case "add": - return { kind: "add", name }; - case "update": - return { kind: "update", name }; - case "delete": - return { kind: "delete", name }; - default: - throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`); - } +export async function updateSkills(): Promise { + return parseSkillsSnapshot(await invokeDesktopCommand("update_skills")); } -function parseSkillsStatus(raw: unknown): SkillsStatus { - if (!isRecord(raw)) { - throw new Error("Unexpected skills status response."); - } - const ops = Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : []; - return { state: parseSkillsState(raw.state), ops }; +export async function uninstallSkills(): Promise { + return parseSkillsSnapshot(await invokeDesktopCommand("uninstall_skills")); } -export async function getSkillsStatus(): Promise { - return parseSkillsStatus(await invokeDesktopCommand("get_skills_status")); -} - -export async function installSkills(): Promise { - return parseSkillsStatus(await invokeDesktopCommand("install_skills")); -} - -export async function updateSkills(): Promise { - return parseSkillsStatus(await invokeDesktopCommand("update_skills")); -} - -export async function uninstallSkills(): Promise { - return parseSkillsStatus(await invokeDesktopCommand("uninstall_skills")); +export async function saveSkillsSelection( + selection: SkillSelection, + confirmedRemovals: readonly string[] = [], +): Promise { + return parseSkillsSaveResult( + await invokeDesktopCommand("save_skills_selection", { + ...selection, + confirmedRemovals: [...confirmedRemovals], + }), + ); } diff --git a/packages/app/src/desktop/daemon/skills-snapshot.test.ts b/packages/app/src/desktop/daemon/skills-snapshot.test.ts new file mode 100644 index 0000000000..3a860deb79 --- /dev/null +++ b/packages/app/src/desktop/daemon/skills-snapshot.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { parseSkillsSaveResult, parseSkillsSnapshot } from "./skills-snapshot"; + +describe("parseSkillsSnapshot", () => { + it("parses a full snapshot from the desktop command surface", () => { + expect( + parseSkillsSnapshot({ + state: "drift", + ops: [ + { kind: "add", name: "paseo-loop" }, + { kind: "delete", name: "paseo-chat" }, + ], + available: ["paseo", "paseo-loop"], + installed: ["paseo"], + selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, + }), + ).toEqual({ + state: "drift", + ops: [ + { kind: "add", name: "paseo-loop" }, + { kind: "delete", name: "paseo-chat" }, + ], + available: ["paseo", "paseo-loop"], + installed: ["paseo"], + selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, + }); + }); + + it("parses the installed skills the host reports", () => { + expect( + parseSkillsSnapshot({ + state: "drift", + ops: [{ kind: "add", name: "paseo-loop" }], + available: ["paseo", "paseo-loop"], + installed: ["paseo-loop", 7], + selection: { mode: "all" }, + }).installed, + ).toEqual(["paseo-loop"]); + }); + + it("assumes the saved selection is installed when the host does not report it", () => { + // An older host has no `installed` field. Assuming the selection is on disk + // keeps the destructive confirmation firing rather than silently skipping it. + expect( + parseSkillsSnapshot({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-advisor", "paseo-loop"], + selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, + }).installed, + ).toEqual(["paseo", "paseo-loop"]); + }); + + it("assumes every bundled skill is installed for an all selection from an older host", () => { + expect( + parseSkillsSnapshot({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-loop"], + }).installed, + ).toEqual(["paseo", "paseo-loop"]); + }); + + it("reads a snapshot with no saved selection as all skills", () => { + expect(parseSkillsSnapshot({ state: "up-to-date", ops: [], available: ["paseo"] })).toEqual({ + state: "up-to-date", + ops: [], + available: ["paseo"], + installed: ["paseo"], + selection: { mode: "all" }, + }); + }); + + it("drops catalog and selection entries that are not skill names", () => { + expect( + parseSkillsSnapshot({ + state: "up-to-date", + ops: [], + available: ["paseo", 7, null, "paseo-loop"], + selection: { mode: "custom", skills: ["paseo", 7] }, + }), + ).toEqual({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-loop"], + installed: ["paseo"], + selection: { mode: "custom", skills: ["paseo"] }, + }); + }); + + it("reads the removals the host wants confirmed", () => { + expect( + parseSkillsSaveResult({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-loop"], + installed: ["paseo", "paseo-loop"], + selection: { mode: "all" }, + confirmationRequired: { removals: ["paseo-loop", 7] }, + }).confirmationRequired, + ).toEqual({ removals: ["paseo-loop"] }); + }); + + it("treats a save with no confirmation request as applied", () => { + expect( + parseSkillsSaveResult({ + state: "up-to-date", + ops: [], + available: ["paseo"], + installed: ["paseo"], + selection: { mode: "all" }, + }).confirmationRequired, + ).toBeNull(); + }); + + it("rejects a response that is not an object", () => { + expect(() => parseSkillsSnapshot("nope")).toThrow("Unexpected skills status response."); + }); + + it("rejects an unknown install state", () => { + expect(() => parseSkillsSnapshot({ state: "half-installed", ops: [] })).toThrow( + "Unexpected skills status state: half-installed", + ); + }); + + it("rejects an unknown pending operation kind", () => { + expect(() => + parseSkillsSnapshot({ state: "drift", ops: [{ kind: "relocate", name: "paseo" }] }), + ).toThrow("Unexpected skill op kind: relocate"); + }); +}); diff --git a/packages/app/src/desktop/daemon/skills-snapshot.ts b/packages/app/src/desktop/daemon/skills-snapshot.ts new file mode 100644 index 0000000000..5129e1215a --- /dev/null +++ b/packages/app/src/desktop/daemon/skills-snapshot.ts @@ -0,0 +1,113 @@ +export type SkillsState = "not-installed" | "up-to-date" | "drift"; + +export type SkillOp = + | { kind: "add"; name: string } + | { kind: "update"; name: string } + | { kind: "delete"; name: string }; + +/** What the user asked to have installed. `all` follows the bundle as it grows. */ +export type SkillSelection = { mode: "all" } | { mode: "custom"; skills: string[] }; + +export interface SkillsSnapshot { + state: SkillsState; + ops: SkillOp[]; + /** Every skill the host currently bundles, sorted. The selectable catalog. */ + available: string[]; + /** + * Skills with a directory in at least one agent home. An `add` op only means + * "missing from at least one target", so it cannot stand in for this. + */ + installed: string[]; + selection: SkillSelection; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseSkillsState(value: unknown): SkillsState { + switch (value) { + case "not-installed": + case "up-to-date": + case "drift": + return value; + default: + throw new Error(`Unexpected skills status state: ${String(value)}`); + } +} + +function parseSkillOp(raw: unknown): SkillOp { + if (!isRecord(raw)) { + throw new Error("Unexpected skill op response."); + } + const name = typeof raw.name === "string" ? raw.name.trim() : ""; + if (!name) throw new Error("Skill op missing name."); + switch (raw.kind) { + case "add": + return { kind: "add", name }; + case "update": + return { kind: "update", name }; + case "delete": + return { kind: "delete", name }; + default: + throw new Error(`Unexpected skill op kind: ${String(raw.kind)}`); + } +} + +function parseSkillNames(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +function parseSkillSelection(value: unknown): SkillSelection { + // A host that predates the picker sends no selection; it manages every + // bundled skill, which is exactly what `all` means. + if (!isRecord(value)) return { mode: "all" }; + if (value.mode === "custom") return { mode: "custom", skills: parseSkillNames(value.skills) }; + return { mode: "all" }; +} + +function selectedSkills(selection: SkillSelection, available: readonly string[]): string[] { + if (selection.mode === "all") return [...available]; + const chosen = new Set(selection.skills); + return available.filter((name) => chosen.has(name)); +} + +export interface SkillsSaveResult extends SkillsSnapshot { + /** + * Non-null when the host refused the save because these directories would be + * deleted. Confirm the names with the user and retry with them. + */ + confirmationRequired: { removals: string[] } | null; +} + +export function parseSkillsSaveResult(raw: unknown): SkillsSaveResult { + const snapshot = parseSkillsSnapshot(raw); + const required = + isRecord(raw) && isRecord(raw.confirmationRequired) + ? parseSkillNames(raw.confirmationRequired.removals) + : []; + return { + ...snapshot, + confirmationRequired: required.length > 0 ? { removals: required } : null, + }; +} + +export function parseSkillsSnapshot(raw: unknown): SkillsSnapshot { + if (!isRecord(raw)) { + throw new Error("Unexpected skills status response."); + } + const available = parseSkillNames(raw.available); + const selection = parseSkillSelection(raw.selection); + return { + state: parseSkillsState(raw.state), + ops: Array.isArray(raw.ops) ? raw.ops.map(parseSkillOp) : [], + available, + // A host that predates this field cannot say what is on disk. Assume the + // saved selection is, so removing a skill still asks before deleting it. + installed: Array.isArray(raw.installed) + ? parseSkillNames(raw.installed) + : selectedSkills(selection, available), + selection, + }; +} diff --git a/packages/app/src/desktop/hooks/use-install-status.test.tsx b/packages/app/src/desktop/hooks/use-install-status.test.tsx index 9610788a9a..fda4869864 100644 --- a/packages/app/src/desktop/hooks/use-install-status.test.tsx +++ b/packages/app/src/desktop/hooks/use-install-status.test.tsx @@ -17,10 +17,11 @@ const toast = vi.hoisted(() => ({ const desktopDaemon = vi.hoisted(() => ({ getCliInstallStatus: vi.fn(), installCli: vi.fn(), - getSkillsStatus: vi.fn(), + getSkillsSnapshot: vi.fn(), installSkills: vi.fn(), updateSkills: vi.fn(), uninstallSkills: vi.fn(), + saveSkillsSelection: vi.fn(), shouldUseDesktopDaemon: vi.fn(() => true), })); @@ -127,7 +128,7 @@ describe("useSkillsStatus", () => { }); it("loads the current skills status", async () => { - desktopDaemon.getSkillsStatus.mockResolvedValue({ + desktopDaemon.getSkillsSnapshot.mockResolvedValue({ state: "up-to-date", ops: [], }); @@ -142,7 +143,7 @@ describe("useSkillsStatus", () => { }); it("install transitions a not-installed status to up-to-date and reflects the response directly", async () => { - desktopDaemon.getSkillsStatus.mockResolvedValue({ + desktopDaemon.getSkillsSnapshot.mockResolvedValue({ state: "not-installed", ops: [{ kind: "add", name: "paseo" }], }); @@ -165,7 +166,7 @@ describe("useSkillsStatus", () => { }); it("update transitions drift to up-to-date", async () => { - desktopDaemon.getSkillsStatus.mockResolvedValue({ + desktopDaemon.getSkillsSnapshot.mockResolvedValue({ state: "drift", ops: [{ kind: "update", name: "paseo" }], }); @@ -188,7 +189,7 @@ describe("useSkillsStatus", () => { }); it("uninstall transitions up-to-date back to not-installed", async () => { - desktopDaemon.getSkillsStatus.mockResolvedValue({ state: "up-to-date", ops: [] }); + desktopDaemon.getSkillsSnapshot.mockResolvedValue({ state: "up-to-date", ops: [] }); desktopDaemon.uninstallSkills.mockResolvedValue({ state: "not-installed", ops: [{ kind: "add", name: "paseo" }], @@ -214,7 +215,7 @@ describe("useSkillsStatus", () => { }); it("isWorking flips while a mutation is in flight", async () => { - desktopDaemon.getSkillsStatus.mockResolvedValue({ + desktopDaemon.getSkillsSnapshot.mockResolvedValue({ state: "not-installed", ops: [{ kind: "add", name: "paseo" }], }); @@ -256,7 +257,7 @@ describe("useSkillsStatus", () => { it("toasts and exposes errors when install fails", async () => { const error = new Error("Missing IPC handler"); - desktopDaemon.getSkillsStatus.mockResolvedValue({ + desktopDaemon.getSkillsSnapshot.mockResolvedValue({ state: "not-installed", ops: [{ kind: "add", name: "paseo" }], }); diff --git a/packages/app/src/desktop/hooks/use-install-status.ts b/packages/app/src/desktop/hooks/use-install-status.ts index 5c2ddda6e3..44595710c7 100644 --- a/packages/app/src/desktop/hooks/use-install-status.ts +++ b/packages/app/src/desktop/hooks/use-install-status.ts @@ -3,12 +3,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getCliInstallStatus, - getSkillsStatus, + getSkillsSnapshot, installCli, installSkills, + saveSkillsSelection, shouldUseDesktopDaemon, type InstallStatus, - type SkillsStatus, + type SkillSelection, + type SkillsSaveResult, + type SkillsSnapshot, uninstallSkills, updateSkills, } from "@/desktop/daemon/desktop-daemon"; @@ -79,7 +82,7 @@ export function useCliInstall(): DesktopInstallHookResult { } export interface SkillsStatusHookResult { - status: SkillsStatus | null; + status: SkillsSnapshot | null; isLoading: boolean; isWorking: boolean; error: Error | null; @@ -87,6 +90,10 @@ export interface SkillsStatusHookResult { install: () => Promise; update: () => Promise; uninstall: () => Promise; + saveSelection: ( + selection: SkillSelection, + confirmedRemovals?: readonly string[], + ) => Promise; } export function useSkillsStatus(): SkillsStatusHookResult { @@ -95,9 +102,9 @@ export function useSkillsStatus(): SkillsStatusHookResult { const reportError = useDesktopIpcErrorReporter(); const enabled = shouldUseDesktopDaemon(); - const statusQuery = useQuery({ + const statusQuery = useQuery({ queryKey: SKILLS_STATUS_QUERY_KEY, - queryFn: getSkillsStatus, + queryFn: getSkillsSnapshot, enabled, retry: false, }); @@ -109,13 +116,13 @@ export function useSkillsStatus(): SkillsStatusHookResult { }); const setStatus = useCallback( - (next: SkillsStatus) => { - queryClient.setQueryData(SKILLS_STATUS_QUERY_KEY, next); + (next: SkillsSnapshot) => { + queryClient.setQueryData(SKILLS_STATUS_QUERY_KEY, next); }, [queryClient], ); - const installMutation = useMutation({ + const installMutation = useMutation({ mutationFn: installSkills, onError: (error) => { reportError({ @@ -127,7 +134,7 @@ export function useSkillsStatus(): SkillsStatusHookResult { onSuccess: setStatus, }); - const updateMutation = useMutation({ + const updateMutation = useMutation({ mutationFn: updateSkills, onError: (error) => { reportError({ @@ -139,7 +146,7 @@ export function useSkillsStatus(): SkillsStatusHookResult { onSuccess: setStatus, }); - const uninstallMutation = useMutation({ + const uninstallMutation = useMutation({ mutationFn: uninstallSkills, onError: (error) => { reportError({ @@ -151,8 +158,28 @@ export function useSkillsStatus(): SkillsStatusHookResult { onSuccess: setStatus, }); + const saveSelectionMutation = useMutation< + SkillsSaveResult, + Error, + { selection: SkillSelection; confirmedRemovals: readonly string[] } + >({ + mutationFn: ({ selection, confirmedRemovals }) => + saveSkillsSelection(selection, confirmedRemovals), + onError: (error) => { + reportError({ + error, + message: t("desktop.integrations.skills.saveSelectionFailed"), + logLabel: "[Integrations] Failed to save skills selection", + }); + }, + onSuccess: setStatus, + }); + const isWorking = - installMutation.isPending || updateMutation.isPending || uninstallMutation.isPending; + installMutation.isPending || + updateMutation.isPending || + uninstallMutation.isPending || + saveSelectionMutation.isPending; const refresh = useCallback(async () => { await refetch(); @@ -170,6 +197,12 @@ export function useSkillsStatus(): SkillsStatusHookResult { await uninstallMutation.mutateAsync().catch(() => undefined); }, [uninstallMutation]); + const saveSelection = useCallback( + async (selection: SkillSelection, confirmedRemovals: readonly string[] = []) => + saveSelectionMutation.mutateAsync({ selection, confirmedRemovals }), + [saveSelectionMutation], + ); + return { status: status ?? null, isLoading, @@ -179,10 +212,12 @@ export function useSkillsStatus(): SkillsStatusHookResult { installMutation.error ?? updateMutation.error ?? uninstallMutation.error ?? + saveSelectionMutation.error ?? null, refresh, install, update, uninstall, + saveSelection, }; } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c60d7ea16c..c24cb3ddb7 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1160,6 +1160,7 @@ export const ar: TranslationResources = { installFailed: "غير قادر على تثبيت مهارات التنسيق.", updateFailed: "غير قادر على تحديث مهارات التنسيق.", uninstallFailed: "غير قادر على إلغاء تثبيت مهارات التنسيق.", + saveSelectionFailed: "غير قادر على حفظ اختيار مهارات التنسيق.", }, }, }, @@ -1863,6 +1864,15 @@ export const ar: TranslationResources = { updateFallback: "مزامنة المهارات المجمعة لجهازك.", uninstallTitle: "إلغاء تثبيت مهارات Paseo ؟", uninstallMessage: "يزيل جميع مهارات تنسيق Paseo من ~/.agents ، ~/.claude ، ~/.codex.", + choose: "اختيار المهارات", + chooseAll: "كل المهارات", + chooseAllHint: "أبقِ جميع المهارات المرفقة مثبتة، بما فيها ما يُضاف لاحقًا.", + chooseList: "المهارات المرفقة", + chooseEmpty: "لا تتضمن هذه النسخة أي مهارات.", + removeTitle: "إزالة المهارات غير المحددة؟", + removeMessage: + "سيتم حذف {{skills}} من ~/.agents و~/.claude و~/.codex. كما يُحذف كل ما أضفته داخل مجلدات المهارات تلك.", + saveFailed: "تعذر حفظ اختيار المهارات.", }, actions: { install: "ثَبَّتَ", @@ -1870,7 +1880,10 @@ export const ar: TranslationResources = { installed: "تم التثبيت", update: "تحديث", working: "عمل...", + remove: "إزالة", uninstall: "إلغاء التثبيت", + save: "حفظ", + saving: "جارٍ الحفظ...", }, operations: { add: "أضف مهارة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index b3407ebf38..dd0191c7ad 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1170,6 +1170,7 @@ export const en = { installFailed: "Unable to install orchestration skills.", updateFailed: "Unable to update orchestration skills.", uninstallFailed: "Unable to uninstall orchestration skills.", + saveSelectionFailed: "Unable to save the orchestration skills selection.", }, }, }, @@ -1874,6 +1875,15 @@ export const en = { uninstallTitle: "Uninstall Paseo skills?", uninstallMessage: "Removes all Paseo orchestration skills from ~/.agents, ~/.claude, ~/.codex.", + choose: "Choose skills", + chooseAll: "All skills", + chooseAllHint: "Keep every bundled skill installed, including ones added later.", + chooseList: "Bundled skills", + chooseEmpty: "This build bundles no skills.", + removeTitle: "Remove deselected skills?", + removeMessage: + "{{skills}} will be deleted from ~/.agents, ~/.claude, and ~/.codex. Anything you added inside those skill folders is deleted too.", + saveFailed: "Could not save your skill selection.", }, actions: { install: "Install", @@ -1881,7 +1891,10 @@ export const en = { installed: "Installed", update: "Update", working: "Working...", + remove: "Remove", uninstall: "Uninstall", + save: "Save", + saving: "Saving...", }, operations: { add: "Add skill", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7c9f20eab7..ebc95b0b7c 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1200,6 +1200,7 @@ export const es: TranslationResources = { installFailed: "No se pueden instalar habilidades de orquestación.", updateFailed: "No se pueden actualizar las habilidades de orquestación.", uninstallFailed: "No se pueden desinstalar las habilidades de orquestación.", + saveSelectionFailed: "No se puede guardar la selección de habilidades de orquestación.", }, }, }, @@ -1912,6 +1913,16 @@ export const es: TranslationResources = { uninstallTitle: "¿Desinstalar las habilidadesPaseo?", uninstallMessage: "Elimina todas las habilidades de orquestaciónPaseode ~/.agents, ~/.claude, ~/.codex.", + choose: "Elegir habilidades", + chooseAll: "Todas las habilidades", + chooseAllHint: + "Mantén instaladas todas las habilidades incluidas, también las que se agreguen después.", + chooseList: "Habilidades incluidas", + chooseEmpty: "Esta versión no incluye habilidades.", + removeTitle: "¿Eliminar las habilidades deseleccionadas?", + removeMessage: + "{{skills}} se eliminarán de ~/.agents, ~/.claude y ~/.codex. También se elimina todo lo que hayas añadido dentro de esas carpetas de habilidades.", + saveFailed: "No se pudo guardar tu selección de habilidades.", }, actions: { install: "Instalar", @@ -1919,7 +1930,10 @@ export const es: TranslationResources = { installed: "Instalado", update: "Actualizar", working: "Laboral...", + remove: "Eliminar", uninstall: "Desinstalar", + save: "Guardar", + saving: "Guardando...", }, operations: { add: "Agregar habilidad", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6b6b138853..ab59cee828 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1202,6 +1202,8 @@ export const fr: TranslationResources = { installFailed: "Impossible d'installer les compétences d'orchestration.", updateFailed: "Impossible de mettre à jour les compétences d'orchestration.", uninstallFailed: "Impossible de désinstaller les compétences d'orchestration.", + saveSelectionFailed: + "Impossible d'enregistrer la sélection des compétences d'orchestration.", }, }, }, @@ -1915,6 +1917,16 @@ export const fr: TranslationResources = { uninstallTitle: "Désinstaller les compétencesPaseo?", uninstallMessage: "Supprime toutes les compétences d'orchestrationPaseode ~/.agents, ~/.claude, ~/.codex.", + choose: "Choisir les compétences", + chooseAll: "Toutes les compétences", + chooseAllHint: + "Gardez installées toutes les compétences fournies, y compris celles ajoutées plus tard.", + chooseList: "Compétences fournies", + chooseEmpty: "Cette version ne fournit aucune compétence.", + removeTitle: "Supprimer les compétences décochées ?", + removeMessage: + "{{skills}} seront supprimées de ~/.agents, ~/.claude et ~/.codex. Tout ce que vous avez ajouté dans ces dossiers de compétences est également supprimé.", + saveFailed: "Impossible d'enregistrer votre sélection de compétences.", }, actions: { install: "Installer", @@ -1922,7 +1934,10 @@ export const fr: TranslationResources = { installed: "Installé", update: "Mise à jour", working: "Fonctionnement...", + remove: "Supprimer", uninstall: "Désinstaller", + save: "Enregistrer", + saving: "Enregistrement...", }, operations: { add: "Ajouter une compétence", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 21bc2106ec..0cd8669133 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1173,6 +1173,7 @@ export const ja: TranslationResources = { installFailed: "オーケストレーションスキルをインストールできません。", updateFailed: "オーケストレーションスキルを更新できません。", uninstallFailed: "オーケストレーションスキルをアンインストールできません。", + saveSelectionFailed: "オーケストレーションスキルの選択を保存できません。", }, }, }, @@ -1881,6 +1882,16 @@ export const ja: TranslationResources = { uninstallTitle: "Paseoスキルをアンインストールしますか?", uninstallMessage: "~/.agents、~/.claude、~/.codexからすべてのPaseoオーケストレーションスキルを削除します。", + choose: "スキルを選択", + chooseAll: "すべてのスキル", + chooseAllHint: + "後から追加されるものも含め、バンドルされたスキルをすべてインストールしたままにします。", + chooseList: "バンドルされたスキル", + chooseEmpty: "このビルドにはスキルが含まれていません。", + removeTitle: "選択を外したスキルを削除しますか?", + removeMessage: + "{{skills}} を ~/.agents、~/.claude、~/.codex から削除します。それらのスキルフォルダー内に追加したものもすべて削除されます。", + saveFailed: "スキルの選択を保存できませんでした。", }, actions: { install: "インストール", @@ -1888,7 +1899,10 @@ export const ja: TranslationResources = { installed: "インストール済み", update: "更新", working: "処理中...", + remove: "削除", uninstall: "アンインストール", + save: "保存", + saving: "保存中...", }, operations: { add: "スキルを追加", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2eeae3863a..a826e149c9 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1186,6 +1186,7 @@ export const ptBR: TranslationResources = { installFailed: "Não foi possível instalar as skills de orquestração.", updateFailed: "Não foi possível atualizar as skills de orquestração.", uninstallFailed: "Não foi possível desinstalar as skills de orquestração.", + saveSelectionFailed: "Não foi possível salvar a seleção de skills de orquestração.", }, }, }, @@ -1896,6 +1897,16 @@ export const ptBR: TranslationResources = { uninstallTitle: "Desinstalar Paseo skills?", uninstallMessage: "Remove todas as skills de orquestração do Paseo de ~/.agents, ~/.claude e ~/.codex.", + choose: "Escolher skills", + chooseAll: "Todas as skills", + chooseAllHint: + "Mantenha instaladas todas as skills incluídas, inclusive as adicionadas depois.", + chooseList: "Skills incluídas", + chooseEmpty: "Esta versão não inclui skills.", + removeTitle: "Remover as skills desmarcadas?", + removeMessage: + "{{skills}} serão excluídas de ~/.agents, ~/.claude e ~/.codex. Tudo o que você adicionou dentro dessas pastas de skills também é excluído.", + saveFailed: "Não foi possível salvar sua seleção de skills.", }, actions: { install: "Instalar", @@ -1903,7 +1914,10 @@ export const ptBR: TranslationResources = { installed: "Instalado", update: "Atualizar", working: "Trabalhando...", + remove: "Remover", uninstall: "Desinstalar", + save: "Salvar", + saving: "Salvando...", }, operations: { add: "Adicionar skill", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 355c8657fe..2e2fbc30af 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1189,6 +1189,7 @@ export const ru: TranslationResources = { installFailed: "Невозможно установить навыки оркестровки.", updateFailed: "Невозможно обновить навыки оркестровки.", uninstallFailed: "Невозможно удалить навыки оркестровки.", + saveSelectionFailed: "Не удалось сохранить выбор навыков оркестровки.", }, }, }, @@ -1901,6 +1902,15 @@ export const ru: TranslationResources = { updateFallback: "Синхронизируйте связанные навыки с вашим компьютером.", uninstallTitle: "Удалить навыки Paseo?", uninstallMessage: "Удаляет все навыки оркестровки Paseo из ~/.agents, ~/.claude, ~/.codex.", + choose: "Выбрать навыки", + chooseAll: "Все навыки", + chooseAllHint: "Держите установленными все входящие навыки, включая добавленные позже.", + chooseList: "Входящие навыки", + chooseEmpty: "В этой сборке нет навыков.", + removeTitle: "Удалить снятые навыки?", + removeMessage: + "{{skills}} будут удалены из ~/.agents, ~/.claude и ~/.codex. Всё, что вы добавили внутри этих папок навыков, тоже удаляется.", + saveFailed: "Не удалось сохранить выбор навыков.", }, actions: { install: "Установить", @@ -1908,7 +1918,10 @@ export const ru: TranslationResources = { installed: "Установлено", update: "Обновлять", working: "Работающий...", + remove: "Удалить", uninstall: "Удалить", + save: "Сохранить", + saving: "Сохранение...", }, operations: { add: "Добавить навык", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 645d840763..0fb6af49c2 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1144,6 +1144,7 @@ export const zhCN: TranslationResources = { installFailed: "无法安装编排 skills。", updateFailed: "无法更新编排 skills。", uninstallFailed: "无法卸载编排 skills。", + saveSelectionFailed: "无法保存编排 skills 的选择。", }, }, }, @@ -1842,6 +1843,15 @@ export const zhCN: TranslationResources = { updateFallback: "将内置 skills 同步到你的机器。", uninstallTitle: "卸载 Paseo skills?", uninstallMessage: "会从 ~/.agents、~/.claude、~/.codex 移除所有 Paseo 编排 skills。", + choose: "选择 skills", + chooseAll: "全部 skills", + chooseAllHint: "保持安装所有内置 skills,包括以后新增的。", + chooseList: "内置 skills", + chooseEmpty: "此版本未内置 skills。", + removeTitle: "移除取消勾选的 skills?", + removeMessage: + "将从 ~/.agents、~/.claude、~/.codex 中删除 {{skills}}。你在这些 skill 文件夹里添加的内容也会一并删除。", + saveFailed: "无法保存你的 skills 选择。", }, actions: { install: "安装", @@ -1849,7 +1859,10 @@ export const zhCN: TranslationResources = { installed: "已安装", update: "更新", working: "处理中...", + remove: "移除", uninstall: "卸载", + save: "保存", + saving: "正在保存...", }, operations: { add: "添加 skill", diff --git a/packages/app/src/styles/settings.ts b/packages/app/src/styles/settings.ts index 08f685d809..2e5d514c07 100644 --- a/packages/app/src/styles/settings.ts +++ b/packages/app/src/styles/settings.ts @@ -11,13 +11,6 @@ export const settingsStyles = StyleSheet.create((theme) => ({ marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, - sectionTitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - marginBottom: theme.spacing[3], - marginLeft: theme.spacing[1], - }, sectionHeaderTitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 6bac555852..982a620066 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -23,12 +23,7 @@ import { getCliInstallStatus, installCli, } from "../integrations/cli-install/index.js"; -import { - getSkillsStatus, - installSkills, - uninstallSkills, - updateSkills, -} from "../integrations/skills/index.js"; +import { createSkillsCommandHandlers, getSkillsController } from "../integrations/skills/index.js"; import { openLocalTransportSession, sendLocalTransportMessage, @@ -623,10 +618,7 @@ export function createDaemonCommandHandlers(): Record getLocalDaemonVersion(), install_cli: () => installCli(), get_cli_install_status: () => getCliInstallStatus(), - get_skills_status: () => getSkillsStatus(), - install_skills: () => installSkills(), - update_skills: () => updateSkills(), - uninstall_skills: () => uninstallSkills(), + ...createSkillsCommandHandlers({ controller: getSkillsController() }), }; } diff --git a/packages/desktop/src/integrations/skills/index.ts b/packages/desktop/src/integrations/skills/index.ts index d568e74364..49306a9953 100644 --- a/packages/desktop/src/integrations/skills/index.ts +++ b/packages/desktop/src/integrations/skills/index.ts @@ -1,11 +1,49 @@ -export { - autoUpdateInstalledSkills, - getSkillsStatus, - installSkills, - uninstallSkills, - updateSkills, - type SkillOp, - type SkillsState, - type SkillsStatus, - type SkillTargets, +import { app } from "electron"; + +import type { SkillsStatus, SkillTargets } from "./operations.js"; +import { + getAgentsSkillsDir, + getBundledSkillsDir, + getClaudeSkillsDir, + getCodexSkillsDir, +} from "./paths.js"; +import { createSkillSelectionStore } from "./selection-store.js"; +import { createSkillsController, type SkillsController } from "./skills-controller.js"; + +export { createSkillsCommandHandlers } from "./skills-commands.js"; +export { type SkillsController, type SkillsSnapshot } from "./skills-controller.js"; +export type { + SkillOp, + SkillSelection, + SkillsState, + SkillsStatus, + SkillTargets, } from "./operations.js"; + +let controller: SkillsController | null = null; + +function getSkillTargets(): SkillTargets { + return { + sourceDir: getBundledSkillsDir(), + agentsDir: getAgentsSkillsDir(), + claudeDir: getClaudeSkillsDir(), + codexDir: getCodexSkillsDir(), + }; +} + +/** + * One controller per process. Startup convergence and the settings commands must + * share it, or they are two writers over the same directories again. + */ +export function getSkillsController(): SkillsController { + controller ??= createSkillsController({ + resolveTargets: getSkillTargets, + selectionStore: createSkillSelectionStore({ userDataPath: app.getPath("userData") }), + }); + return controller; +} + +/** Startup convergence: keep whatever the user selected in sync with the bundle. */ +export async function autoUpdateInstalledSkills(): Promise { + return getSkillsController().autoUpdate(); +} diff --git a/packages/desktop/src/integrations/skills/operations.test.ts b/packages/desktop/src/integrations/skills/operations.test.ts index 1980a29ec8..6b188d24b0 100644 --- a/packages/desktop/src/integrations/skills/operations.test.ts +++ b/packages/desktop/src/integrations/skills/operations.test.ts @@ -1,25 +1,25 @@ import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("electron", () => ({ - app: { - getPath: vi.fn(() => "/tmp/paseo-user-data"), - isPackaged: false, - }, -})); +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { autoUpdateInstalledSkills, getSkillsStatus, installSkills, - PASEO_SKILL_NAMES, + LEGACY_SKILL_NAMES, + type SkillSelection, type SkillTargets, uninstallSkills, updateSkills, } from "./operations"; +const ALL_SKILLS: SkillSelection = { mode: "all" }; + +function only(...skills: string[]): SkillSelection { + return { mode: "custom", skills }; +} + interface Sandbox { root: string; targets: SkillTargets; @@ -85,6 +85,14 @@ async function pathExists(p: string): Promise { .catch(() => false); } +async function installedIn(targets: SkillTargets, name: string): Promise { + return Promise.all( + [targets.agentsDir, targets.claudeDir, targets.codexDir].map((dir) => + pathExists(path.join(dir, name)), + ), + ); +} + describe("getSkillsStatus", () => { let sandbox: Sandbox; @@ -99,7 +107,7 @@ describe("getSkillsStatus", () => { it("returns not-installed with add ops for every bundled skill when nothing is on disk", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); - const status = await getSkillsStatus(sandbox.targets); + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("not-installed"); expect(status.ops).toEqual([ @@ -108,13 +116,46 @@ describe("getSkillsStatus", () => { ]); }); + it("reports every bundled skill as available", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeBundleSkill(sandbox.targets.sourceDir, "paseo-advisor", { + "SKILL.md": "advisor-v1", + }); + + const status = await getSkillsStatus(sandbox.targets, only("paseo")); + + expect(status.available).toEqual(["paseo", "paseo-advisor", "paseo-loop"]); + }); + + it("reports a skill present in only one target as installed", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo", { "SKILL.md": "paseo-v1" }); + + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); + + // `add` means "missing from at least one target", so it cannot answer + // "is there a directory here to delete". `installed` answers that. + expect(status.installed).toEqual(["paseo"]); + expect(status.ops).toEqual([ + { kind: "add", name: "paseo" }, + { kind: "add", name: "paseo-loop" }, + ]); + }); + + it("reports legacy skill directories left on disk as installed", async () => { + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" }); + + expect((await getSkillsStatus(sandbox.targets, ALL_SKILLS)).installed).toEqual(["paseo-chat"]); + }); + it("returns not-installed when only user-personal skill dirs exist (the live bug)", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); for (const name of ["unslop", "tdd", "devbox"]) { await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` }); } - const status = await getSkillsStatus(sandbox.targets); + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("not-installed"); expect(status.ops).toEqual([ @@ -128,9 +169,14 @@ describe("getSkillsStatus", () => { 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); + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); - expect(status).toEqual({ state: "up-to-date", ops: [] }); + expect(status).toEqual({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-loop"], + installed: ["paseo", "paseo-loop"], + }); }); it("ignores user-added files inside current managed skill dirs in every target", async () => { @@ -150,9 +196,14 @@ describe("getSkillsStatus", () => { "hooks/guard.sh": "user guard", }); - const status = await getSkillsStatus(sandbox.targets); + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); - expect(status).toEqual({ state: "up-to-date", ops: [] }); + expect(status).toEqual({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-loop"], + installed: ["paseo", "paseo-loop"], + }); }); it("returns drift with a single update op when one bundled file diverges", async () => { @@ -162,7 +213,7 @@ describe("getSkillsStatus", () => { 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); + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("drift"); expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]); @@ -177,7 +228,7 @@ describe("getSkillsStatus", () => { 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); + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("drift"); expect(status.ops).toEqual([{ kind: "update", name: "paseo" }]); @@ -187,7 +238,7 @@ describe("getSkillsStatus", () => { await writeCurrentBundle(sandbox.targets.sourceDir); await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo", { "SKILL.md": "paseo-v1" }); - const status = await getSkillsStatus(sandbox.targets); + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("drift"); expect(status.ops).toEqual([{ kind: "add", name: "paseo-loop" }]); @@ -199,7 +250,7 @@ describe("getSkillsStatus", () => { 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); + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("drift"); expect(status.ops).toEqual([{ kind: "delete", name: "paseo-chat" }]); @@ -212,7 +263,7 @@ describe("getSkillsStatus", () => { 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); + const status = await getSkillsStatus(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("drift"); expect(status.ops).toEqual([ @@ -223,6 +274,116 @@ describe("getSkillsStatus", () => { }); }); +describe("custom skill selection", () => { + let sandbox: Sandbox; + + beforeEach(async () => { + sandbox = await makeSandbox(); + await writeCurrentBundle(sandbox.targets.sourceDir); + await writeBundleSkill(sandbox.targets.sourceDir, "paseo-advisor", { + "SKILL.md": "advisor-v1", + }); + }); + + afterEach(async () => { + await fs.rm(sandbox.root, { recursive: true, force: true }); + }); + + it("installs only the selected skills", async () => { + const status = await installSkills(sandbox.targets, only("paseo", "paseo-loop")); + + expect(status).toEqual({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-advisor", "paseo-loop"], + installed: ["paseo", "paseo-loop"], + }); + expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]); + expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([true, true, true]); + expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]); + }); + + it("reports up-to-date while an unselected bundled skill is absent", async () => { + await installSkills(sandbox.targets, only("paseo")); + + const status = await getSkillsStatus(sandbox.targets, only("paseo")); + + expect(status).toEqual({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-advisor", "paseo-loop"], + installed: ["paseo"], + }); + }); + + it("removes a previously installed skill once it leaves the selection", async () => { + await installSkills(sandbox.targets, ALL_SKILLS); + + const status = await installSkills(sandbox.targets, only("paseo")); + + expect(status.state).toBe("up-to-date"); + expect(await installedIn(sandbox.targets, "paseo")).toEqual([true, true, true]); + expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([false, false, false]); + expect(await installedIn(sandbox.targets, "paseo-advisor")).toEqual([false, false, false]); + }); + + it("reports a delete op for a deselected skill before it is applied", async () => { + await installSkills(sandbox.targets, ALL_SKILLS); + + const status = await getSkillsStatus(sandbox.targets, only("paseo", "paseo-advisor")); + + expect(status.state).toBe("drift"); + expect(status.ops).toEqual([{ kind: "delete", name: "paseo-loop" }]); + }); + + it("leaves unrelated user skills untouched", async () => { + await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" }); + + await installSkills(sandbox.targets, only("paseo")); + + expect( + await fs.readFile(path.join(sandbox.targets.agentsDir, "unslop", "SKILL.md"), "utf-8"), + ).toBe("user-unslop"); + }); + + it("treats an empty custom selection as no managed skills installed", async () => { + await installSkills(sandbox.targets, ALL_SKILLS); + + const status = await installSkills(sandbox.targets, only()); + + expect(status).toEqual({ + state: "not-installed", + ops: [], + available: ["paseo", "paseo-advisor", "paseo-loop"], + installed: [], + }); + expect(await installedIn(sandbox.targets, "paseo")).toEqual([false, false, false]); + expect(await installedIn(sandbox.targets, "paseo-loop")).toEqual([false, false, false]); + }); + + it("ignores selected names that the bundle does not ship", async () => { + const status = await installSkills(sandbox.targets, only("paseo", "not-a-skill")); + + expect(status).toEqual({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-advisor", "paseo-loop"], + installed: ["paseo"], + }); + expect(await installedIn(sandbox.targets, "not-a-skill")).toEqual([false, false, false]); + }); + + it("still deletes legacy skill names that are not selectable", async () => { + await writeOnDiskSkillToAllTargets(sandbox.targets, "paseo-orchestrator", { + "SKILL.md": "orchestrator-old", + }); + + await installSkills(sandbox.targets, only("paseo")); + + expect(await installedIn(sandbox.targets, "paseo-orchestrator")).toEqual([false, false, false]); + }); +}); + describe("installSkills / updateSkills", () => { let sandbox: Sandbox; @@ -238,9 +399,14 @@ describe("installSkills / updateSkills", () => { await writeCurrentBundle(sandbox.targets.sourceDir); await writeOnDiskSkill(sandbox.targets.agentsDir, "unslop", { "SKILL.md": "user-unslop" }); - const status = await installSkills(sandbox.targets); + const status = await installSkills(sandbox.targets, ALL_SKILLS); - expect(status).toEqual({ state: "up-to-date", ops: [] }); + expect(status).toEqual({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-loop"], + installed: ["paseo", "paseo-loop"], + }); for (const name of ["paseo", "paseo-loop"]) { expect( await fs.readFile(path.join(sandbox.targets.agentsDir, name, "SKILL.md"), "utf-8"), @@ -255,16 +421,21 @@ describe("installSkills / updateSkills", () => { ).toBe("user-unslop"); }); - it("converges to up-to-date when state has missing + edited + legacy skills", async () => { + it("repairs missing and edited skills without deleting a legacy directory", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo", { "SKILL.md": "stale" }); await writeOnDiskSkill(sandbox.targets.agentsDir, "paseo-chat", { "SKILL.md": "chat-old" }); await writeOnDiskSkill(sandbox.targets.claudeDir, "paseo-chat", { "SKILL.md": "chat-old" }); await writeOnDiskSkill(sandbox.targets.codexDir, "paseo-chat", { "SKILL.md": "chat-old" }); - const status = await updateSkills(sandbox.targets); + const status = await updateSkills(sandbox.targets, ALL_SKILLS); - expect(status).toEqual({ state: "up-to-date", ops: [] }); + expect(status).toEqual({ + state: "drift", + ops: [{ kind: "delete", name: "paseo-chat" }], + available: ["paseo", "paseo-loop"], + installed: ["paseo", "paseo-chat", "paseo-loop"], + }); expect( await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"), ).toBe("paseo-v1"); @@ -276,7 +447,7 @@ describe("installSkills / updateSkills", () => { sandbox.targets.claudeDir, sandbox.targets.codexDir, ]) { - expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false); + expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(true); } }); @@ -297,10 +468,15 @@ describe("installSkills / updateSkills", () => { "prompts/local.md": "codex prompt", }); - const status = await updateSkills(sandbox.targets); + const status = await updateSkills(sandbox.targets, ALL_SKILLS); - expect(status).toEqual({ state: "up-to-date", ops: [] }); - expect(await getSkillsStatus(sandbox.targets)).toEqual({ state: "up-to-date", ops: [] }); + expect(status.state).toBe("up-to-date"); + expect(await getSkillsStatus(sandbox.targets, ALL_SKILLS)).toEqual({ + state: "up-to-date", + ops: [], + available: ["paseo", "paseo-loop"], + installed: ["paseo", "paseo-loop"], + }); expect( await fs.readFile( path.join(sandbox.targets.agentsDir, "paseo", "hooks", "guard.sh"), @@ -329,9 +505,9 @@ describe("installSkills / updateSkills", () => { 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); + const status = await updateSkills(sandbox.targets, ALL_SKILLS); - expect(status).toEqual({ state: "up-to-date", ops: [] }); + expect(status.state).toBe("up-to-date"); expect( await fs.readFile(path.join(sandbox.targets.claudeDir, "paseo-loop", "SKILL.md"), "utf-8"), ).toBe("loop-v1"); @@ -344,10 +520,10 @@ describe("installSkills / updateSkills", () => { "hooks/guard.sh": "user guard", }); - const status = await autoUpdateInstalledSkills(sandbox.targets); + const status = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS); - expect(status).toEqual({ state: "up-to-date", ops: [] }); - expect(await getSkillsStatus(sandbox.targets)).toEqual({ state: "up-to-date", ops: [] }); + expect(status.state).toBe("up-to-date"); + expect((await getSkillsStatus(sandbox.targets, ALL_SKILLS)).state).toBe("up-to-date"); expect( await fs.readFile(path.join(sandbox.targets.agentsDir, "paseo", "SKILL.md"), "utf-8"), ).toBe("paseo-v1"); @@ -362,7 +538,7 @@ describe("installSkills / updateSkills", () => { it("does not auto-install skills on a clean machine", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); - const status = await autoUpdateInstalledSkills(sandbox.targets); + const status = await autoUpdateInstalledSkills(sandbox.targets, ALL_SKILLS); expect(status).toEqual({ state: "not-installed", @@ -370,20 +546,20 @@ describe("installSkills / updateSkills", () => { { kind: "add", name: "paseo" }, { kind: "add", name: "paseo-loop" }, ], + available: ["paseo", "paseo-loop"], + installed: [], }); - 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); + expect(await installedIn(sandbox.targets, "paseo")).toEqual([false, false, false]); }); it("is idempotent — running install twice keeps state at up-to-date", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); - const first = await installSkills(sandbox.targets); - const second = await installSkills(sandbox.targets); + const first = await installSkills(sandbox.targets, ALL_SKILLS); + const second = await installSkills(sandbox.targets, ALL_SKILLS); - expect(first).toEqual({ state: "up-to-date", ops: [] }); - expect(second).toEqual({ state: "up-to-date", ops: [] }); + expect(first.state).toBe("up-to-date"); + expect(second.state).toBe("up-to-date"); }); }); @@ -400,18 +576,16 @@ describe("uninstallSkills", () => { it("removes every Paseo skill from all three targets and preserves user dirs", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); - await installSkills(sandbox.targets); + await installSkills(sandbox.targets, ALL_SKILLS); for (const name of ["unslop", "tdd", "devbox"]) { await writeOnDiskSkill(sandbox.targets.agentsDir, name, { "SKILL.md": `user-${name}` }); } - const status = await uninstallSkills(sandbox.targets); + const status = await uninstallSkills(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("not-installed"); - for (const name of PASEO_SKILL_NAMES) { - expect(await pathExists(path.join(sandbox.targets.agentsDir, name))).toBe(false); - expect(await pathExists(path.join(sandbox.targets.claudeDir, name))).toBe(false); - expect(await pathExists(path.join(sandbox.targets.codexDir, name))).toBe(false); + for (const name of ["paseo", "paseo-loop", ...LEGACY_SKILL_NAMES]) { + expect(await installedIn(sandbox.targets, name)).toEqual([false, false, false]); } for (const name of ["unslop", "tdd", "devbox"]) { expect( @@ -423,7 +597,7 @@ describe("uninstallSkills", () => { it("is a no-op when nothing is installed", async () => { await writeCurrentBundle(sandbox.targets.sourceDir); - const status = await uninstallSkills(sandbox.targets); + const status = await uninstallSkills(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("not-installed"); }); @@ -438,15 +612,9 @@ describe("uninstallSkills", () => { await writeOnDiskSkill(dir, "paseo-chat", { "SKILL.md": "chat-old" }); } - const status = await uninstallSkills(sandbox.targets); + const status = await uninstallSkills(sandbox.targets, ALL_SKILLS); expect(status.state).toBe("not-installed"); - for (const dir of [ - sandbox.targets.agentsDir, - sandbox.targets.claudeDir, - sandbox.targets.codexDir, - ]) { - expect(await pathExists(path.join(dir, "paseo-chat"))).toBe(false); - } + expect(await installedIn(sandbox.targets, "paseo-chat")).toEqual([false, false, false]); }); }); diff --git a/packages/desktop/src/integrations/skills/operations.ts b/packages/desktop/src/integrations/skills/operations.ts index 0a226e9f86..da118a171c 100644 --- a/packages/desktop/src/integrations/skills/operations.ts +++ b/packages/desktop/src/integrations/skills/operations.ts @@ -1,12 +1,6 @@ import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; -import { - getAgentsSkillsDir, - getBundledSkillsDir, - getClaudeSkillsDir, - getCodexSkillsDir, -} from "./paths.js"; import { listFilesRecursive, removeSkill, syncSkills } from "./sync.js"; export type SkillsState = "not-installed" | "up-to-date" | "drift"; @@ -16,9 +10,20 @@ export type SkillOp = | { kind: "update"; name: string } | { kind: "delete"; name: string }; +/** What the user asked to have installed. `all` follows the bundle as it grows. */ +export type SkillSelection = { mode: "all" } | { mode: "custom"; skills: readonly string[] }; + export interface SkillsStatus { state: SkillsState; ops: SkillOp[]; + /** Every skill the bundle currently ships, sorted. The selectable catalog. */ + available: string[]; + /** + * Managed skills with a directory in at least one agent home, sorted. An `add` + * op means "missing from at least one target", so it cannot answer whether + * there is anything on disk to delete — this can. + */ + installed: string[]; } export interface SkillTargets { @@ -28,14 +33,10 @@ export interface SkillTargets { codexDir: string; } -export const PASEO_SKILL_NAMES = [ - "paseo", - "paseo-advisor", +// Names the bundle used to ship. They are never selectable, but every scan still +// covers them so an older install's copies get cleaned up. +export const LEGACY_SKILL_NAMES = [ "paseo-chat", - "paseo-committee", - "paseo-handoff", - "paseo-loop", - // Keep removed bundle names here so auto-update deletes stale installed copies. "paseo-epic", "paseo-orchestrate", "paseo-orchestrator", @@ -44,13 +45,35 @@ export const PASEO_SKILL_NAMES = [ type SkillFiles = Map; type TargetSkills = Map; -function resolveSkillTargets(): SkillTargets { - return { - sourceDir: getBundledSkillsDir(), - agentsDir: getAgentsSkillsDir(), - claudeDir: getClaudeSkillsDir(), - codexDir: getCodexSkillsDir(), - }; +/** + * The bundle directory is the catalog. Reading it instead of a hardcoded list is + * what makes `all` pick up skills added in a later release with no code change. + */ +async function listBundledSkills(sourceDir: string): Promise { + const entries = await fs.readdir(sourceDir, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(compareStrings); +} + +/** Every name Paseo owns on disk: what it ships now plus what it used to ship. */ +function managedSkillNames(available: readonly string[]): string[] { + return [...new Set([...available, ...LEGACY_SKILL_NAMES])].sort(compareStrings); +} + +/** The names a convergence may create, replace, or delete. */ +export async function listManagedSkillNames(sourceDir: string): Promise { + return managedSkillNames(await listBundledSkills(sourceDir)); +} + +function resolveDesiredSkills( + selection: SkillSelection, + available: readonly string[], +): Set { + if (selection.mode === "all") return new Set(available); + const chosen = new Set(selection.skills); + return new Set(available.filter((name) => chosen.has(name))); } async function hashSkillDir(skillDir: string): Promise { @@ -67,19 +90,24 @@ async function hashSkillDir(skillDir: string): Promise { return files; } -async function hashSkills(rootDir: string): Promise> { - const out = new Map(); - for (const name of PASEO_SKILL_NAMES) { +async function hashSkills(rootDir: string, names: readonly string[]): Promise { + const out: TargetSkills = new Map(); + for (const name of names) { const files = await hashSkillDir(path.join(rootDir, name)); if (files !== null) out.set(name, files); } return out; } -function diff(bundle: TargetSkills, disks: readonly TargetSkills[]): SkillOp[] { +function diff( + bundle: TargetSkills, + disks: readonly TargetSkills[], + names: readonly string[], + desired: ReadonlySet, +): SkillOp[] { const ops: SkillOp[] = []; - for (const name of PASEO_SKILL_NAMES) { - const b = bundle.get(name); + for (const name of names) { + const b = desired.has(name) ? bundle.get(name) : undefined; const targetFiles = disks.map((disk) => disk.get(name)); const installedTargets = targetFiles.filter( (files): files is SkillFiles => files !== undefined, @@ -101,6 +129,10 @@ function hasInstalledPaseoSkill(disks: readonly TargetSkills[]): boolean { return disks.some((disk) => disk.size > 0); } +function installedSkillNames(disks: readonly TargetSkills[], names: readonly string[]): string[] { + return names.filter((name) => disks.some((disk) => disk.has(name))); +} + function bundleFilesMatch(bundle: SkillFiles, disk: SkillFiles): boolean { for (const [rel, sha] of bundle) { if (disk.get(rel) !== sha) return false; @@ -118,27 +150,33 @@ function compareStrings(a: string, b: string): number { return 0; } -export async function getSkillsStatus(targets?: SkillTargets): Promise { - const t = targets ?? resolveSkillTargets(); +export async function getSkillsStatus( + targets: SkillTargets, + selection: SkillSelection, +): Promise { + const available = await listBundledSkills(targets.sourceDir); + const names = managedSkillNames(available); const [bundle, agentsDisk, claudeDisk, codexDisk] = await Promise.all([ - hashSkills(t.sourceDir), - hashSkills(t.agentsDir), - hashSkills(t.claudeDir), - hashSkills(t.codexDir), + hashSkills(targets.sourceDir, available), + hashSkills(targets.agentsDir, names), + hashSkills(targets.claudeDir, names), + hashSkills(targets.codexDir, names), ]); const disks = [agentsDisk, claudeDisk, codexDisk]; - const ops = diff(bundle, disks); + const ops = diff(bundle, disks, names, resolveDesiredSkills(selection, available)); + const installed = installedSkillNames(disks, names); - if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops }; - if (ops.length === 0) return { state: "up-to-date", ops }; - return { state: "drift", ops }; + if (!hasInstalledPaseoSkill(disks)) return { state: "not-installed", ops, available, installed }; + if (ops.length === 0) return { state: "up-to-date", ops, available, installed }; + return { state: "drift", ops, available, installed }; } async function applySkills( targets: SkillTargets, + selection: SkillSelection, initialStatus?: SkillsStatus, ): Promise { - const status = initialStatus ?? (await getSkillsStatus(targets)); + const status = initialStatus ?? (await getSkillsStatus(targets, selection)); const writes = status.ops .filter((op) => op.kind === "add" || op.kind === "update") @@ -162,32 +200,52 @@ async function applySkills( }); } - return getSkillsStatus(targets); + return getSkillsStatus(targets, selection); } -export async function installSkills(targets?: SkillTargets): Promise { - return applySkills(targets ?? resolveSkillTargets()); +export async function installSkills( + targets: SkillTargets, + selection: SkillSelection, + /** Apply exactly this plan instead of rescanning, so a confirmed plan is the applied plan. */ + plan?: SkillsStatus, +): Promise { + return applySkills(targets, selection, plan); } -export async function updateSkills(targets?: SkillTargets): Promise { - return applySkills(targets ?? resolveSkillTargets()); +export async function updateSkills( + targets: SkillTargets, + selection: SkillSelection, +): Promise { + const status = await getSkillsStatus(targets, selection); + return applySkills(targets, selection, nonDestructivePlan(status)); } -export async function autoUpdateInstalledSkills(targets?: SkillTargets): Promise { - const t = targets ?? resolveSkillTargets(); - const status = await getSkillsStatus(t); +function nonDestructivePlan(status: SkillsStatus): SkillsStatus { + return { ...status, ops: status.ops.filter((op) => op.kind !== "delete") }; +} + +export async function autoUpdateInstalledSkills( + targets: SkillTargets, + selection: SkillSelection, +): Promise { + const status = await getSkillsStatus(targets, selection); if (status.state !== "drift") return status; - return applySkills(t, status); + // Automatic maintenance may repair selected skills, but removal is an + // interactive operation because managed directories can contain user files. + return applySkills(targets, selection, nonDestructivePlan(status)); } -export async function uninstallSkills(targets?: SkillTargets): Promise { - const t = targets ?? resolveSkillTargets(); - for (const name of PASEO_SKILL_NAMES) { +export async function uninstallSkills( + targets: SkillTargets, + selection: SkillSelection, +): Promise { + const available = await listBundledSkills(targets.sourceDir); + for (const name of managedSkillNames(available)) { await removeSkill(name, { - agentsDir: t.agentsDir, - claudeDir: t.claudeDir, - codexDir: t.codexDir, + agentsDir: targets.agentsDir, + claudeDir: targets.claudeDir, + codexDir: targets.codexDir, }); } - return getSkillsStatus(t); + return getSkillsStatus(targets, selection); } diff --git a/packages/desktop/src/integrations/skills/selection-store.test.ts b/packages/desktop/src/integrations/skills/selection-store.test.ts new file mode 100644 index 0000000000..3f1ddc9321 --- /dev/null +++ b/packages/desktop/src/integrations/skills/selection-store.test.ts @@ -0,0 +1,150 @@ +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createSkillSelectionStore } from "./selection-store"; + +const directories = new Set(); + +async function createTempUserDataDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "paseo-skill-selection-")); + directories.add(dir); + return dir; +} + +function selectionFilePath(userDataPath: string): string { + return path.join(userDataPath, "skill-selection.json"); +} + +async function seedSelectionFile(userDataPath: string, raw: string): Promise { + await writeFile(selectionFilePath(userDataPath), raw, "utf8"); +} + +afterEach(async () => { + for (const dir of directories) { + await rm(dir, { recursive: true, force: true }); + } + directories.clear(); +}); + +describe("createSkillSelectionStore", () => { + it("reports all skills when nothing has ever been saved", async () => { + const userDataPath = await createTempUserDataDir(); + const store = createSkillSelectionStore({ userDataPath }); + + expect(await store.get()).toEqual({ mode: "all" }); + expect(await readdir(userDataPath)).toEqual([]); + }); + + it("round-trips a custom selection through a fresh store", async () => { + const userDataPath = await createTempUserDataDir(); + + const saved = await createSkillSelectionStore({ userDataPath }).set({ + mode: "custom", + skills: ["paseo", "paseo-loop"], + }); + const reloaded = await createSkillSelectionStore({ userDataPath }).get(); + + expect(saved).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] }); + expect(reloaded).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] }); + expect(await readdir(userDataPath)).toEqual(["skill-selection.json"]); + }); + + it("sorts, de-duplicates, and drops non-string names before persisting", async () => { + const userDataPath = await createTempUserDataDir(); + const store = createSkillSelectionStore({ userDataPath }); + + const saved = await store.set({ + mode: "custom", + skills: ["paseo-loop", "paseo", "paseo-loop", " ", 7, null], + }); + + expect(saved).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] }); + expect(JSON.parse(await readFile(selectionFilePath(userDataPath), "utf8"))).toEqual({ + version: 1, + selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, + }); + }); + + it("keeps an empty custom selection as a deliberate choice", async () => { + const userDataPath = await createTempUserDataDir(); + + await createSkillSelectionStore({ userDataPath }).set({ mode: "custom", skills: [] }); + + expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ + mode: "custom", + skills: [], + }); + }); + + it("switches back to all and forgets the previous custom names", async () => { + const userDataPath = await createTempUserDataDir(); + const store = createSkillSelectionStore({ userDataPath }); + await store.set({ mode: "custom", skills: ["paseo"] }); + + const saved = await store.set({ mode: "all" }); + + expect(saved).toEqual({ mode: "all" }); + expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ mode: "all" }); + }); + + it("falls back to all when the saved file is not valid JSON", async () => { + const userDataPath = await createTempUserDataDir(); + await seedSelectionFile(userDataPath, "{ not valid json"); + + expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ mode: "all" }); + }); + + it("falls back to all when the saved mode is unknown", async () => { + const userDataPath = await createTempUserDataDir(); + await seedSelectionFile( + userDataPath, + JSON.stringify({ version: 1, selection: { mode: "some-future-mode", skills: ["paseo"] } }), + ); + + expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ mode: "all" }); + }); + + it("normalizes a custom selection that was hand-edited on disk", async () => { + const userDataPath = await createTempUserDataDir(); + await seedSelectionFile( + userDataPath, + JSON.stringify({ + version: 1, + selection: { mode: "custom", skills: ["paseo-loop", 3, "paseo", "paseo"] }, + }), + ); + + expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ + mode: "custom", + skills: ["paseo", "paseo-loop"], + }); + }); + + it("treats a custom selection with no usable skills array as an empty selection", async () => { + const userDataPath = await createTempUserDataDir(); + await seedSelectionFile( + userDataPath, + JSON.stringify({ version: 1, selection: { mode: "custom" } }), + ); + + expect(await createSkillSelectionStore({ userDataPath }).get()).toEqual({ + mode: "custom", + skills: [], + }); + }); + + it("leaves no temp files behind when saves overlap", async () => { + const userDataPath = await createTempUserDataDir(); + const store = createSkillSelectionStore({ userDataPath }); + + await Promise.all([ + store.set({ mode: "custom", skills: ["paseo"] }), + store.set({ mode: "all" }), + store.set({ mode: "custom", skills: ["paseo-loop"] }), + ]); + + expect(await readdir(userDataPath)).toEqual(["skill-selection.json"]); + }); +}); diff --git a/packages/desktop/src/integrations/skills/selection-store.ts b/packages/desktop/src/integrations/skills/selection-store.ts new file mode 100644 index 0000000000..3de94c6511 --- /dev/null +++ b/packages/desktop/src/integrations/skills/selection-store.ts @@ -0,0 +1,101 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import type { SkillSelection } from "./operations.js"; + +export interface SkillSelectionStore { + get(): Promise; + set(selection: unknown): Promise; +} + +interface PersistedSkillSelectionDocument { + version: 1; + selection: SkillSelection; +} + +const SKILL_SELECTION_FILENAME = "skill-selection.json"; + +// Installs that predate the picker have no file. They keep every bundled skill, +// which is what they had before, and what `all` keeps meaning as the bundle grows. +const DEFAULT_SKILL_SELECTION: SkillSelection = { mode: "all" }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error; +} + +export function coerceSkillNames(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const names = value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + return [...new Set(names)].sort(); +} + +/** + * The one place an unvalidated selection becomes a `SkillSelection`. Whether a + * name is actually in the bundle is not decided here — the catalog belongs to + * the bundle directory, so `operations` resolves that against the live catalog. + */ +export function coerceSkillSelection(value: unknown): SkillSelection { + if (!isRecord(value)) return DEFAULT_SKILL_SELECTION; + if (value.mode === "all") return { mode: "all" }; + if (value.mode === "custom") return { mode: "custom", skills: coerceSkillNames(value.skills) }; + return DEFAULT_SKILL_SELECTION; +} + +function coerceDocument(value: unknown): PersistedSkillSelectionDocument { + const selection = isRecord(value) + ? coerceSkillSelection(value.selection) + : DEFAULT_SKILL_SELECTION; + return { version: 1, selection }; +} + +export function createSkillSelectionStore({ + userDataPath, +}: { + userDataPath: string; +}): SkillSelectionStore { + const filePath = path.join(userDataPath, SKILL_SELECTION_FILENAME); + let persistQueue: Promise = Promise.resolve(); + + async function persist(document: PersistedSkillSelectionDocument): Promise { + const write = async () => { + await mkdir(userDataPath, { recursive: true }); + const tempFilePath = `${filePath}.tmp.${process.pid}.${randomUUID()}`; + await writeFile(tempFilePath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); + await rename(tempFilePath, filePath); + }; + const queued = persistQueue.then(write, write); + persistQueue = queued.catch(() => undefined); + await queued; + } + + return { + async get(): Promise { + let raw: string; + try { + raw = await readFile(filePath, "utf8"); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") throw error; + return DEFAULT_SKILL_SELECTION; + } + try { + return coerceDocument(JSON.parse(raw)).selection; + } catch { + return DEFAULT_SKILL_SELECTION; + } + }, + + async set(selection: unknown): Promise { + const document = coerceDocument({ selection }); + await persist(document); + return document.selection; + }, + }; +} diff --git a/packages/desktop/src/integrations/skills/skills-commands.ts b/packages/desktop/src/integrations/skills/skills-commands.ts new file mode 100644 index 0000000000..82cee17381 --- /dev/null +++ b/packages/desktop/src/integrations/skills/skills-commands.ts @@ -0,0 +1,16 @@ +import type { DesktopCommandHandler } from "../../settings/desktop-settings-commands.js"; +import type { SkillsController } from "./skills-controller.js"; + +export function createSkillsCommandHandlers({ + controller, +}: { + controller: SkillsController; +}): Record { + return { + get_skills_status: () => controller.status(), + install_skills: () => controller.install(), + update_skills: () => controller.update(), + uninstall_skills: () => controller.uninstall(), + save_skills_selection: (args) => controller.save(args), + }; +} diff --git a/packages/desktop/src/integrations/skills/skills-controller.test.ts b/packages/desktop/src/integrations/skills/skills-controller.test.ts new file mode 100644 index 0000000000..cd94a18f50 --- /dev/null +++ b/packages/desktop/src/integrations/skills/skills-controller.test.ts @@ -0,0 +1,1204 @@ +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + readlink, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { access } from "node:fs/promises"; +import { watch } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { SkillSelection, SkillTargets } from "./operations"; +import { createSkillSelectionStore, type SkillSelectionStore } from "./selection-store"; +import { createSkillsController, type SkillsController } from "./skills-controller"; +import { beginSkillsTransaction } from "./skills-transaction"; + +interface Harness { + root: string; + targets: SkillTargets; + controller: SkillsController; + selectionStore: SkillSelectionStore; +} + +const BUNDLED_SKILLS = ["paseo", "paseo-advisor", "paseo-loop"]; + +async function makeHarness(selectionStore?: SkillSelectionStore): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-skills-controller-")); + const targets: SkillTargets = { + sourceDir: path.join(root, "bundle"), + agentsDir: path.join(root, "home", ".agents", "skills"), + claudeDir: path.join(root, "home", ".claude", "skills"), + codexDir: path.join(root, "home", ".codex", "skills"), + }; + for (const name of BUNDLED_SKILLS) { + await mkdir(path.join(targets.sourceDir, name), { recursive: true }); + await writeFile(path.join(targets.sourceDir, name, "SKILL.md"), `${name}-v1`); + } + const store = + selectionStore ?? createSkillSelectionStore({ userDataPath: path.join(root, "user-data") }); + return { + root, + targets, + controller: createSkillsController({ + resolveTargets: () => targets, + selectionStore: store, + }), + selectionStore: store, + }; +} + +/** + * The selection store is an injected port, so a store that refuses to persist is + * a fake adapter rather than a mock of the code under test. It is the only way + * to reach the "converged, then could not commit" path deterministically. + */ +function createUnwritableSelectionStore(initial: SkillSelection): SkillSelectionStore { + return { + get: async () => initial, + set: async () => { + throw new Error("selection store is read-only"); + }, + }; +} + +function createGatedUnwritableSelectionStore(initial: SkillSelection): { + store: SkillSelectionStore; + persistenceStarted: Promise; + failPersistence(): void; +} { + let markStarted!: () => void; + let fail!: () => void; + const persistenceStarted = new Promise((resolve) => { + markStarted = resolve; + }); + const failureGate = new Promise((resolve) => { + fail = resolve; + }); + return { + store: { + get: async () => initial, + set: async () => { + markStarted(); + await failureGate; + throw new Error("selection store is read-only"); + }, + }, + persistenceStarted, + failPersistence: fail, + }; +} + +function createGatedSelectionStore(initial: SkillSelection): { + store: SkillSelectionStore; + persistenceStarted: Promise; + finishPersistence(): void; +} { + let current = initial; + let markStarted!: () => void; + let finish!: () => void; + const persistenceStarted = new Promise((resolve) => { + markStarted = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + return { + store: { + get: async () => current, + set: async (selection) => { + markStarted(); + await gate; + current = selection; + }, + }, + persistenceStarted, + finishPersistence: finish, + }; +} + +async function installedSkills(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +async function installedEverywhere(targets: SkillTargets): Promise { + return Promise.all([targets.agentsDir, targets.claudeDir, targets.codexDir].map(installedSkills)); +} + +async function writeUserFile( + targets: SkillTargets, + skill: string, + relativePath: string, + contents: string, +): Promise { + for (const dir of [targets.agentsDir, targets.claudeDir, targets.codexDir]) { + const file = path.join(dir, skill, relativePath); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, contents); + } +} + +async function readUserFile( + targets: SkillTargets, + skill: string, + relativePath: string, +): Promise> { + return Promise.all( + [targets.agentsDir, targets.claudeDir, targets.codexDir].map((dir) => + readFile(path.join(dir, skill, relativePath), "utf8").catch(() => null), + ), + ); +} + +/** Anything the transaction staged and failed to clean up sits beside the skills tree. */ +async function backupArtifacts(targets: SkillTargets): Promise { + return Promise.all( + [targets.agentsDir, targets.claudeDir, targets.codexDir].map(async (dir) => { + const parentEntries = await readdir(path.dirname(dir)).catch(() => []); + const rootEntries = await readdir(dir).catch(() => []); + return [ + ...parentEntries.filter( + (entry) => entry !== path.basename(dir) && !entry.startsWith(".paseo-skills-recovered-"), + ), + ...rootEntries.filter((entry) => entry.startsWith(".paseo-skills-transaction-")), + ].sort(); + }), + ); +} + +async function waitForTransactionDirectory(parent: string): Promise { + const events = watch(parent); + try { + for await (const event of events) { + if (event.filename?.startsWith(".paseo-skills-transaction-")) return; + } + } finally { + await events.return?.(); + } +} + +/** Puts a regular file where the agents skills tree goes, so convergence fails with ENOTDIR. */ +async function blockAgentsDir(targets: SkillTargets): Promise { + await rm(targets.agentsDir, { recursive: true, force: true }); + await mkdir(path.dirname(targets.agentsDir), { recursive: true }); + await writeFile(targets.agentsDir, "not a directory"); +} + +async function isInstalled(targets: SkillTargets, name: string): Promise { + const dirs = [targets.agentsDir, targets.claudeDir, targets.codexDir]; + const present = await Promise.all( + dirs.map((dir) => + access(path.join(dir, name)) + .then(() => true) + .catch(() => false), + ), + ); + return present.every(Boolean); +} + +describe("skills controller", () => { + let harness: Harness; + + beforeEach(async () => { + harness = await makeHarness(); + }); + + afterEach(async () => { + await rm(harness.root, { recursive: true, force: true }); + }); + + it("reports one snapshot with catalog, selection, status, and pending work", async () => { + expect(await harness.controller.status()).toEqual({ + state: "not-installed", + ops: [ + { kind: "add", name: "paseo" }, + { kind: "add", name: "paseo-advisor" }, + { kind: "add", name: "paseo-loop" }, + ], + available: BUNDLED_SKILLS, + installed: [], + selection: { mode: "all" }, + }); + }); + + it("installs every bundled skill while the selection is all", async () => { + expect(await harness.controller.install()).toEqual({ + state: "up-to-date", + ops: [], + available: BUNDLED_SKILLS, + installed: BUNDLED_SKILLS, + selection: { mode: "all" }, + }); + expect(await isInstalled(harness.targets, "paseo-advisor")).toBe(true); + }); + + it("does not remove deselected directories during install", async () => { + await harness.controller.save({ mode: "custom", skills: ["paseo"] }); + await writeUserFile(harness.targets, "paseo-loop", "notes/mine.md", "keep this"); + + await harness.controller.install(); + + expect(await readUserFile(harness.targets, "paseo-loop", "notes/mine.md")).toEqual([ + "keep this", + "keep this", + "keep this", + ]); + }); + + it("saves a custom selection, converges disk, and returns the refreshed snapshot", async () => { + const snapshot = await harness.controller.save({ + mode: "custom", + skills: ["paseo-loop", "paseo"], + }); + + expect(snapshot).toEqual({ + state: "up-to-date", + ops: [], + available: BUNDLED_SKILLS, + installed: ["paseo", "paseo-loop"], + selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, + confirmationRequired: null, + }); + expect(await isInstalled(harness.targets, "paseo")).toBe(true); + expect(await isInstalled(harness.targets, "paseo-loop")).toBe(true); + expect(await isInstalled(harness.targets, "paseo-advisor")).toBe(false); + }); + + it("removes a skill from disk when it is dropped from the selection", async () => { + await harness.controller.install(); + + await harness.controller.save({ + mode: "custom", + skills: ["paseo"], + confirmedRemovals: ["paseo-advisor", "paseo-loop"], + }); + + expect(await isInstalled(harness.targets, "paseo")).toBe(true); + expect(await isInstalled(harness.targets, "paseo-advisor")).toBe(false); + expect(await isInstalled(harness.targets, "paseo-loop")).toBe(false); + }); + + it("keeps the saved selection after uninstall so a later install restores it", async () => { + await harness.controller.save({ mode: "custom", skills: ["paseo"] }); + + const afterUninstall = await harness.controller.uninstall(); + const afterReinstall = await harness.controller.install(); + + expect(afterUninstall).toEqual({ + state: "not-installed", + ops: [{ kind: "add", name: "paseo" }], + available: BUNDLED_SKILLS, + installed: [], + selection: { mode: "custom", skills: ["paseo"] }, + }); + expect(afterReinstall).toEqual({ + state: "up-to-date", + ops: [], + available: BUNDLED_SKILLS, + installed: ["paseo"], + selection: { mode: "custom", skills: ["paseo"] }, + }); + expect(await isInstalled(harness.targets, "paseo")).toBe(true); + expect(await isInstalled(harness.targets, "paseo-loop")).toBe(false); + }); + + it("treats an empty custom selection as uninstall while keeping the preference", async () => { + await harness.controller.install(); + + const snapshot = await harness.controller.save({ + mode: "custom", + skills: [], + confirmedRemovals: BUNDLED_SKILLS, + }); + + expect(snapshot).toEqual({ + state: "not-installed", + ops: [], + available: BUNDLED_SKILLS, + installed: [], + selection: { mode: "custom", skills: [] }, + confirmationRequired: null, + }); + expect(await isInstalled(harness.targets, "paseo")).toBe(false); + }); + + it("returns to every bundled skill when the selection goes back to all", async () => { + await harness.controller.save({ mode: "custom", skills: ["paseo"] }); + + const snapshot = await harness.controller.save({ mode: "all" }); + + expect(snapshot).toEqual({ + state: "up-to-date", + ops: [], + available: BUNDLED_SKILLS, + installed: BUNDLED_SKILLS, + selection: { mode: "all" }, + confirmationRequired: null, + }); + expect(await isInstalled(harness.targets, "paseo-advisor")).toBe(true); + }); + + it("keeps the previous selection when the save fails to reach disk", async () => { + await harness.controller.save({ mode: "custom", skills: ["paseo"] }); + await blockAgentsDir(harness.targets); + + await expect(harness.controller.save({ mode: "all" })).rejects.toThrow(); + await rm(harness.targets.agentsDir, { force: true }); + + expect(await harness.controller.status()).toEqual({ + state: "drift", + ops: [{ kind: "add", name: "paseo" }], + available: BUNDLED_SKILLS, + installed: ["paseo"], + selection: { mode: "custom", skills: ["paseo"] }, + }); + }); + + it("saves no selection at all when the very first save fails", async () => { + await blockAgentsDir(harness.targets); + + await expect(harness.controller.save({ mode: "custom", skills: ["paseo"] })).rejects.toThrow(); + await rm(harness.targets.agentsDir, { force: true }); + + expect(await harness.controller.status()).toEqual({ + state: "not-installed", + ops: BUNDLED_SKILLS.map((name) => ({ kind: "add", name })), + available: BUNDLED_SKILLS, + installed: [], + selection: { mode: "all" }, + }); + }); + + it("restores deleted directories byte for byte when the selection cannot be committed", async () => { + const store = createUnwritableSelectionStore({ + mode: "custom", + skills: ["paseo", "paseo-loop"], + }); + const readOnly = await makeHarness(store); + await readOnly.controller.install(); + await writeUserFile(readOnly.targets, "paseo-loop", "notes/mine.md", "hand written"); + + // Deselects paseo-loop and adds paseo-advisor, then fails to commit. + await expect( + readOnly.controller.save({ + mode: "custom", + skills: ["paseo", "paseo-advisor"], + confirmedRemovals: ["paseo-loop"], + }), + ).rejects.toThrow("selection store is read-only"); + + expect(await readOnly.controller.status()).toEqual({ + state: "up-to-date", + ops: [], + available: BUNDLED_SKILLS, + installed: ["paseo", "paseo-loop"], + selection: { mode: "custom", skills: ["paseo", "paseo-loop"] }, + }); + expect(await installedEverywhere(readOnly.targets)).toEqual([ + ["paseo", "paseo-loop"], + ["paseo", "paseo-loop"], + ["paseo", "paseo-loop"], + ]); + expect(await readUserFile(readOnly.targets, "paseo-loop", "notes/mine.md")).toEqual([ + "hand written", + "hand written", + "hand written", + ]); + expect(await backupArtifacts(readOnly.targets)).toEqual([[], [], []]); + await rm(readOnly.root, { recursive: true, force: true }); + }); + + it("preserves files added by another writer before rollback", async () => { + const selection: SkillSelection = { mode: "custom", skills: ["paseo"] }; + const gated = createGatedUnwritableSelectionStore(selection); + const readOnly = await makeHarness(gated.store); + await readOnly.controller.install(); + await writeFile(path.join(readOnly.targets.sourceDir, "paseo", "SKILL.md"), "paseo-v2"); + + const save = readOnly.controller.save(selection); + await gated.persistenceStarted; + await writeUserFile(readOnly.targets, "paseo", "notes/concurrent.md", "keep this"); + gated.failPersistence(); + await expect(save).rejects.toThrow("selection store is read-only"); + + expect(await readUserFile(readOnly.targets, "paseo", "notes/concurrent.md")).toEqual([ + "keep this", + "keep this", + "keep this", + ]); + await rm(readOnly.root, { recursive: true, force: true }); + }); + + it("does not automatically delete files preserved from a rolled-back add", async () => { + const previous: SkillSelection = { mode: "custom", skills: ["paseo"] }; + const gated = createGatedUnwritableSelectionStore(previous); + const readOnly = await makeHarness(gated.store); + await readOnly.controller.install(); + + const save = readOnly.controller.save({ + mode: "custom", + skills: ["paseo", "paseo-loop"], + }); + await gated.persistenceStarted; + await writeUserFile(readOnly.targets, "paseo-loop", "notes/concurrent.md", "keep this"); + gated.failPersistence(); + await expect(save).rejects.toThrow("selection store is read-only"); + + await readOnly.controller.autoUpdate(); + expect(await readUserFile(readOnly.targets, "paseo-loop", "notes/concurrent.md")).toEqual([ + "keep this", + "keep this", + "keep this", + ]); + await rm(readOnly.root, { recursive: true, force: true }); + }); + + it("merges a deleted directory backup into files another writer recreated", async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const gated = createGatedUnwritableSelectionStore(previous); + const readOnly = await makeHarness(gated.store); + await readOnly.controller.install(); + await writeUserFile(readOnly.targets, "paseo-loop", "notes/before.md", "restore this"); + + const save = readOnly.controller.save({ + mode: "custom", + skills: ["paseo"], + confirmedRemovals: ["paseo-loop"], + }); + await gated.persistenceStarted; + await writeUserFile(readOnly.targets, "paseo-loop", "notes/concurrent.md", "keep this"); + gated.failPersistence(); + await expect(save).rejects.toThrow("selection store is read-only"); + + expect(await readUserFile(readOnly.targets, "paseo-loop", "notes/before.md")).toEqual([ + "restore this", + "restore this", + "restore this", + ]); + expect(await readUserFile(readOnly.targets, "paseo-loop", "notes/concurrent.md")).toEqual([ + "keep this", + "keep this", + "keep this", + ]); + await rm(readOnly.root, { recursive: true, force: true }); + }); + + it("atomically stages a deletion before another writer can recreate its path", async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; + await harness.controller.save(previous); + + const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "delete", name: "paseo-loop" }, + ]); + + expect(await isInstalled(harness.targets, "paseo-loop")).toBe(false); + await writeUserFile(harness.targets, "paseo-loop", "notes/concurrent.md", "keep this"); + await transaction.rollback(); + expect(await readUserFile(harness.targets, "paseo-loop", "notes/concurrent.md")).toEqual([ + "keep this", + "keep this", + "keep this", + ]); + expect(await isInstalled(harness.targets, "paseo-loop")).toBe(true); + }); + + it.skipIf(process.platform === "win32")( + "renames an untouched staged deletion back with its inode and mode", + async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; + await harness.controller.save(previous); + const livePaths = [ + harness.targets.agentsDir, + harness.targets.claudeDir, + harness.targets.codexDir, + ].map((root) => path.join(root, "paseo-loop")); + for (const live of livePaths) await chmod(live, 0o700); + const before = await Promise.all(livePaths.map(lstat)); + + const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "delete", name: "paseo-loop" }, + ]); + await transaction.rollback(); + + const after = await Promise.all(livePaths.map(lstat)); + expect(after.map((entry) => entry.ino)).toEqual(before.map((entry) => entry.ino)); + expect(after.map((entry) => entry.mode & 0o777)).toEqual([0o700, 0o700, 0o700]); + }, + ); + + it("finishes rollback when an external deletion leaves no live or staged path", async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; + await harness.controller.save(previous); + + const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "delete", name: "paseo-loop" }, + ]); + const codexStage = (await readdir(harness.targets.codexDir)).find((entry) => + entry.startsWith(".paseo-skills-transaction-"), + ); + expect(codexStage).toBeDefined(); + await rm(path.join(harness.targets.codexDir, codexStage!, "paseo-loop"), { + recursive: true, + force: true, + }); + + await transaction.rollback(); + + expect(await readUserFile(harness.targets, "paseo-loop", "SKILL.md")).toEqual([ + "paseo-loop-v1", + "paseo-loop-v1", + null, + ]); + expect(await backupArtifacts(harness.targets)).toEqual([[], [], []]); + await expect(harness.controller.status()).resolves.toMatchObject({ selection: previous }); + }); + + it("quarantines a staged directory when an external file takes its live path", async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; + await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo-loop", "notes/mine.md", "keep this"); + + const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "delete", name: "paseo-loop" }, + ]); + const live = path.join(harness.targets.codexDir, "paseo-loop"); + await writeFile(live, "external replacement"); + + await transaction.rollback(); + + expect(await readFile(live, "utf8")).toBe("external replacement"); + const recovered = (await readdir(harness.targets.codexDir)).find((entry) => + entry.startsWith(".paseo-skills-recovered-"), + ); + expect(recovered).toBeDefined(); + expect( + await readFile(path.join(harness.targets.codexDir, recovered!, "notes", "mine.md"), "utf8"), + ).toBe("keep this"); + expect(await readUserFile(harness.targets, "paseo-loop", "notes/mine.md")).toEqual([ + "keep this", + "keep this", + null, + ]); + expect(await backupArtifacts(harness.targets)).toEqual([[], [], []]); + await expect(harness.controller.status()).resolves.toMatchObject({ selection: previous }); + }); + + it("quarantines staged files that collide with a recreated directory", async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; + await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo-loop", "notes/mine.md", "staged notes"); + + const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "delete", name: "paseo-loop" }, + ]); + const live = path.join(harness.targets.codexDir, "paseo-loop"); + await mkdir(path.join(live, "notes"), { recursive: true }); + await writeFile(path.join(live, "SKILL.md"), "external skill"); + await writeFile(path.join(live, "notes", "mine.md"), "external notes"); + + await transaction.rollback(); + + expect(await readFile(path.join(live, "SKILL.md"), "utf8")).toBe("external skill"); + expect(await readFile(path.join(live, "notes", "mine.md"), "utf8")).toBe("external notes"); + const recovered = (await readdir(harness.targets.codexDir)).find((entry) => + entry.startsWith(".paseo-skills-recovered-paseo-loop-"), + ); + expect(recovered).toBeDefined(); + expect( + await readFile(path.join(harness.targets.codexDir, recovered!, "SKILL.md"), "utf8"), + ).toBe("paseo-loop-v1"); + expect( + await readFile(path.join(harness.targets.codexDir, recovered!, "notes", "mine.md"), "utf8"), + ).toBe("staged notes"); + expect(await backupArtifacts(harness.targets)).toEqual([[], [], []]); + await expect(harness.controller.status()).resolves.toMatchObject({ selection: previous }); + }); + + it("preserves incompatible live paths while rolling back adds and updates", async () => { + const previous: SkillSelection = { mode: "custom", skills: ["paseo"] }; + const next: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-advisor"], + }; + await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo", "notes/mine.md", "keep this"); + + const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "update", name: "paseo" }, + { kind: "add", name: "paseo-advisor" }, + ]); + const replacedUpdate = path.join(harness.targets.agentsDir, "paseo"); + await rm(replacedUpdate, { recursive: true, force: true }); + await writeFile(replacedUpdate, "external update replacement"); + const replacedAdd = path.join(harness.targets.codexDir, "paseo-advisor"); + await writeFile(replacedAdd, "external add replacement"); + + await transaction.rollback(); + + expect(await readFile(replacedUpdate, "utf8")).toBe("external update replacement"); + expect(await readFile(replacedAdd, "utf8")).toBe("external add replacement"); + const recoveryParent = path.dirname(harness.targets.agentsDir); + const recovered = (await readdir(recoveryParent)).find((entry) => + entry.startsWith(".paseo-skills-recovered-paseo-"), + ); + expect(recovered).toBeDefined(); + expect(await readFile(path.join(recoveryParent, recovered!, "notes", "mine.md"), "utf8")).toBe( + "keep this", + ); + expect(await readUserFile(harness.targets, "paseo", "notes/mine.md")).toEqual([ + null, + "keep this", + "keep this", + ]); + expect(await backupArtifacts(harness.targets)).toEqual([[], [], []]); + await expect(harness.controller.status()).resolves.toMatchObject({ selection: previous }); + }); + + it("recovers after an update backup was quarantined before transaction cleanup", async () => { + const previous: SkillSelection = { mode: "custom", skills: ["paseo"] }; + const next: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-advisor"], + }; + await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo", "notes/mine.md", "captured notes"); + + await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "update", name: "paseo" }, + ]); + const transactionParent = path.dirname(harness.targets.agentsDir); + const transactionName = (await readdir(transactionParent)).find((entry) => + entry.startsWith(".paseo-skills-transaction-"), + ); + expect(transactionName).toBeDefined(); + const transactionDir = path.join(transactionParent, transactionName!); + const manifest = JSON.parse( + await readFile(path.join(transactionDir, "transaction.json"), "utf8"), + ) as { entries: Array<{ livePath: string; backupPath: string | null }> }; + const entry = manifest.entries.find( + (candidate) => candidate.livePath === path.join(harness.targets.agentsDir, "paseo"), + ); + expect(entry?.backupPath).toBeTruthy(); + const backup = path.join(transactionDir, entry!.backupPath!); + const recovered = path.join( + transactionParent, + `.paseo-skills-recovered-paseo-${transactionName!.replace(".paseo-skills-transaction-", "")}`, + ); + await rm(entry!.livePath, { recursive: true, force: true }); + await writeFile(entry!.livePath, "external replacement"); + await rename(backup, recovered); + + await expect(harness.controller.status()).resolves.toMatchObject({ selection: previous }); + + expect(await readFile(entry!.livePath, "utf8")).toBe("external replacement"); + expect(await readFile(path.join(recovered, "notes", "mine.md"), "utf8")).toBe("captured notes"); + expect(await backupArtifacts(harness.targets)).toEqual([[], [], []]); + }); + + it("preserves a directory that replaces a captured file before recovery", async () => { + const previous: SkillSelection = { mode: "custom", skills: ["paseo"] }; + const next: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-advisor"], + }; + await harness.controller.save(previous); + const live = path.join(harness.targets.agentsDir, "paseo"); + await rm(live, { recursive: true, force: true }); + await writeFile(live, "captured file"); + + await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "update", name: "paseo" }, + ]); + await rm(live, { force: true }); + await mkdir(live, { recursive: true }); + await writeFile(path.join(live, "external.md"), "external directory"); + + await expect(harness.controller.status()).resolves.toMatchObject({ selection: previous }); + + expect(await readFile(path.join(live, "external.md"), "utf8")).toBe("external directory"); + const recovered = (await readdir(path.dirname(harness.targets.agentsDir))).find((entry) => + entry.startsWith(".paseo-skills-recovered-paseo-"), + ); + expect(recovered).toBeDefined(); + expect( + await readFile(path.join(path.dirname(harness.targets.agentsDir), recovered!), "utf8"), + ).toBe("captured file"); + expect(await backupArtifacts(harness.targets)).toEqual([[], [], []]); + }); + + it.skipIf(process.platform !== "linux")( + "stages deletions when an agent skills root is on another filesystem", + async () => { + const crossFilesystemRoot = await mkdtemp("/dev/shm/paseo-skills-controller-"); + try { + harness.targets.claudeDir = path.join(crossFilesystemRoot, "skills"); + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; + await harness.controller.save(previous); + + expect((await lstat(harness.targets.agentsDir)).dev).not.toBe( + (await lstat(harness.targets.claudeDir)).dev, + ); + const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "delete", name: "paseo-loop" }, + ]); + + expect(await isInstalled(harness.targets, "paseo-loop")).toBe(false); + await transaction.rollback(); + expect(await isInstalled(harness.targets, "paseo-loop")).toBe(true); + } finally { + await rm(crossFilesystemRoot, { recursive: true, force: true }); + } + }, + ); + + it.skipIf(process.platform === "win32")( + "restores a relative symlink used as the skill directory", + async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; + await harness.controller.save(previous); + const shared = path.join(harness.root, "home", "shared", "paseo-loop"); + await mkdir(shared, { recursive: true }); + await writeFile(path.join(shared, "SKILL.md"), "shared target"); + const live = path.join(harness.targets.claudeDir, "paseo-loop"); + await rm(live, { recursive: true, force: true }); + await symlink(path.relative(harness.targets.claudeDir, shared), live, "dir"); + + const transaction = await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "delete", name: "paseo-loop" }, + ]); + await transaction.rollback(); + + expect((await lstat(live)).isSymbolicLink()).toBe(true); + expect(await readFile(path.join(live, "SKILL.md"), "utf8")).toBe("shared target"); + }, + ); + + it.skipIf(process.platform === "win32")( + "restores updates made through a relative skill-directory symlink", + async () => { + const selection: SkillSelection = { mode: "custom", skills: ["paseo"] }; + const gated = createGatedUnwritableSelectionStore(selection); + const readOnly = await makeHarness(gated.store); + await readOnly.controller.install(); + const shared = path.join(readOnly.root, "home", "shared", "paseo"); + await mkdir(shared, { recursive: true }); + await writeFile(path.join(shared, "SKILL.md"), "paseo-v1"); + const live = path.join(readOnly.targets.claudeDir, "paseo"); + await rm(live, { recursive: true, force: true }); + await symlink(path.relative(readOnly.targets.claudeDir, shared), live, "dir"); + await writeFile(path.join(readOnly.targets.sourceDir, "paseo", "SKILL.md"), "paseo-v2"); + + const save = readOnly.controller.save(selection); + await gated.persistenceStarted; + gated.failPersistence(); + await expect(save).rejects.toThrow("selection store is read-only"); + + expect((await lstat(live)).isSymbolicLink()).toBe(true); + expect(await readFile(path.join(shared, "SKILL.md"), "utf8")).toBe("paseo-v1"); + await rm(readOnly.root, { recursive: true, force: true }); + }, + ); + + it.skipIf(process.platform === "win32")( + "stages one deletion when two agent roots alias the same directory", + async () => { + await harness.controller.install(); + await rm(harness.targets.claudeDir, { recursive: true, force: true }); + await symlink( + path.relative(path.dirname(harness.targets.claudeDir), harness.targets.agentsDir), + harness.targets.claudeDir, + "dir", + ); + + const result = await harness.controller.save({ + mode: "custom", + skills: ["paseo", "paseo-advisor"], + confirmedRemovals: ["paseo-loop"], + }); + + expect(result.confirmationRequired).toBeNull(); + expect(await isInstalled(harness.targets, "paseo-loop")).toBe(false); + }, + ); + + it.skipIf(process.platform === "win32")( + "restores a user-added symlink when deletion rolls back", + async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const gated = createGatedUnwritableSelectionStore(previous); + const readOnly = await makeHarness(gated.store); + await readOnly.controller.install(); + for (const root of [ + readOnly.targets.agentsDir, + readOnly.targets.claudeDir, + readOnly.targets.codexDir, + ]) { + const notes = path.join(root, "paseo-loop", "notes"); + await mkdir(notes, { recursive: true }); + await writeFile(path.join(notes, "before.md"), "target"); + await symlink("before.md", path.join(notes, "latest.md")); + } + + const save = readOnly.controller.save({ + mode: "custom", + skills: ["paseo"], + confirmedRemovals: ["paseo-loop"], + }); + await gated.persistenceStarted; + gated.failPersistence(); + await expect(save).rejects.toThrow("selection store is read-only"); + + for (const root of [ + readOnly.targets.agentsDir, + readOnly.targets.claudeDir, + readOnly.targets.codexDir, + ]) { + const restored = path.join(root, "paseo-loop", "notes", "latest.md"); + expect((await lstat(restored)).isSymbolicLink()).toBe(true); + expect(await readlink(restored)).toBe("before.md"); + } + await rm(readOnly.root, { recursive: true, force: true }); + }, + ); + + it.skipIf(process.platform === "win32")( + "restores executable permissions when deletion rolls back", + async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const gated = createGatedUnwritableSelectionStore(previous); + const readOnly = await makeHarness(gated.store); + await readOnly.controller.install(); + await writeUserFile(readOnly.targets, "paseo-loop", "hooks/run.sh", "#!/bin/sh\n"); + for (const root of [ + readOnly.targets.agentsDir, + readOnly.targets.claudeDir, + readOnly.targets.codexDir, + ]) { + await chmod(path.join(root, "paseo-loop", "hooks", "run.sh"), 0o751); + } + + const save = readOnly.controller.save({ + mode: "custom", + skills: ["paseo"], + confirmedRemovals: ["paseo-loop"], + }); + await gated.persistenceStarted; + gated.failPersistence(); + await expect(save).rejects.toThrow("selection store is read-only"); + + for (const root of [ + readOnly.targets.agentsDir, + readOnly.targets.claudeDir, + readOnly.targets.codexDir, + ]) { + const restored = await lstat(path.join(root, "paseo-loop", "hooks", "run.sh")); + expect(restored.mode & 0o777).toBe(0o751); + } + await rm(readOnly.root, { recursive: true, force: true }); + }, + ); + + it("leaves no backup artifacts behind after a successful save", async () => { + await harness.controller.save({ mode: "custom", skills: ["paseo"] }); + + expect(await backupArtifacts(harness.targets)).toEqual([[], [], []]); + }); + + it.skipIf(process.platform === "win32")( + "reports a committed save as successful when cleanup is deferred", + async () => { + const gated = createGatedSelectionStore({ mode: "all" }); + const blocked = await makeHarness(gated.store); + const next: SkillSelection = { mode: "custom", skills: ["paseo"] }; + const parent = path.dirname(blocked.targets.agentsDir); + const movedParent = `${parent}-moved`; + + const save = blocked.controller.save(next); + await gated.persistenceStarted; + await rename(parent, movedParent); + await writeFile(parent, "block transaction cleanup with ENOTDIR"); + gated.finishPersistence(); + const outcome = await save.then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + await rm(parent, { force: true }); + await rename(movedParent, parent); + + expect(outcome.ok).toBe(true); + expect(await gated.store.get()).toEqual(next); + await blocked.controller.status(); + expect(await backupArtifacts(blocked.targets)).toEqual([[], [], []]); + await rm(blocked.root, { recursive: true, force: true }); + }, + ); + + it("does not delete an unrelated file that resembles transaction staging", async () => { + const unrelated = path.join( + path.dirname(harness.targets.agentsDir), + ".paseo-skills-transaction-my-notes", + ); + await mkdir(unrelated, { recursive: true }); + await writeFile(path.join(unrelated, "mine.md"), "keep me"); + + await harness.controller.save({ mode: "custom", skills: ["paseo"] }); + + expect(await readFile(path.join(unrelated, "mine.md"), "utf8")).toBe("keep me"); + }); + + it("recovers an interrupted save before the next controller operation", async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const next: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-advisor"], + }; + await harness.controller.save(previous); + await writeUserFile(harness.targets, "paseo-loop", "notes/mine.md", "hand written"); + await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "delete", name: "paseo-loop" }, + ]); + for (const root of [ + harness.targets.agentsDir, + harness.targets.claudeDir, + harness.targets.codexDir, + ]) { + await rm(path.join(root, "paseo-loop"), { recursive: true, force: true }); + } + + await harness.controller.status(); + + expect(await readUserFile(harness.targets, "paseo-loop", "notes/mine.md")).toEqual([ + "hand written", + "hand written", + "hand written", + ]); + expect(await backupArtifacts(harness.targets)).toEqual([[], [], []]); + }); + + it("does not roll back an interrupted transaction after the selection committed", async () => { + const previous: SkillSelection = { + mode: "custom", + skills: ["paseo", "paseo-loop"], + }; + const next: SkillSelection = { + mode: "custom", + skills: ["paseo"], + }; + await harness.controller.save(previous); + await beginSkillsTransaction(harness.targets, previous, next, [ + { kind: "delete", name: "paseo-loop" }, + ]); + for (const root of [ + harness.targets.agentsDir, + harness.targets.claudeDir, + harness.targets.codexDir, + ]) { + await rm(path.join(root, "paseo-loop"), { recursive: true, force: true }); + } + await harness.selectionStore.set(next); + + const snapshot = await harness.controller.status(); + + expect(snapshot.selection).toEqual(next); + expect(await isInstalled(harness.targets, "paseo-loop")).toBe(false); + expect(await backupArtifacts(harness.targets)).toEqual([[], [], []]); + }); + + it("asks for confirmation naming the directories a save would delete", async () => { + await harness.controller.save({ mode: "custom", skills: ["paseo", "paseo-loop"] }); + // Something puts a managed directory back after the UI took its snapshot. + await writeUserFile(harness.targets, "paseo-advisor", "SKILL.md", "external"); + + const result = await harness.controller.save({ + mode: "custom", + skills: ["paseo", "paseo-loop"], + }); + + expect(result.confirmationRequired).toEqual({ removals: ["paseo-advisor"] }); + expect(await installedEverywhere(harness.targets)).toEqual([ + ["paseo", "paseo-advisor", "paseo-loop"], + ["paseo", "paseo-advisor", "paseo-loop"], + ["paseo", "paseo-advisor", "paseo-loop"], + ]); + expect(result.selection).toEqual({ mode: "custom", skills: ["paseo", "paseo-loop"] }); + }); + + it("applies the save once the removals are confirmed", async () => { + await harness.controller.install(); + + const result = await harness.controller.save({ + mode: "custom", + skills: ["paseo"], + confirmedRemovals: ["paseo-advisor", "paseo-loop"], + }); + + expect(result.confirmationRequired).toBeNull(); + expect(result.selection).toEqual({ mode: "custom", skills: ["paseo"] }); + expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], ["paseo"]]); + }); + + it("asks again when another directory appears before the retry", async () => { + await harness.controller.install(); + await writeUserFile(harness.targets, "paseo-chat", "SKILL.md", "retired but present"); + + const result = await harness.controller.save({ + mode: "custom", + skills: ["paseo"], + confirmedRemovals: ["paseo-advisor", "paseo-loop"], + }); + + expect(result.confirmationRequired).toEqual({ + removals: ["paseo-advisor", "paseo-chat", "paseo-loop"], + }); + expect(await installedEverywhere(harness.targets)).toEqual([ + ["paseo", "paseo-advisor", "paseo-chat", "paseo-loop"], + ["paseo", "paseo-advisor", "paseo-chat", "paseo-loop"], + ["paseo", "paseo-advisor", "paseo-chat", "paseo-loop"], + ]); + }); + + it("does not commit when a new removal appears while the frozen plan is applying", async () => { + const selection: SkillSelection = { mode: "custom", skills: ["paseo"] }; + await harness.controller.save(selection); + await writeFile(path.join(harness.targets.sourceDir, "paseo", "SKILL.md"), "paseo-v2"); + + const transactionStarted = waitForTransactionDirectory(path.dirname(harness.targets.agentsDir)); + const save = harness.controller.save(selection); + await transactionStarted; + await writeUserFile(harness.targets, "paseo-chat", "notes/mine.md", "hand written"); + + const result = await save; + + expect(result.confirmationRequired).toEqual({ removals: ["paseo-chat"] }); + expect(result.selection).toEqual(selection); + expect(await readUserFile(harness.targets, "paseo-chat", "notes/mine.md")).toEqual([ + "hand written", + "hand written", + "hand written", + ]); + }); + + it("saves without asking when nothing would be deleted", async () => { + const result = await harness.controller.save({ mode: "custom", skills: ["paseo"] }); + + expect(result.confirmationRequired).toBeNull(); + expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], ["paseo"]]); + }); + + it("preserves a regular file at a skill path when save convergence fails", async () => { + await mkdir(harness.targets.agentsDir, { recursive: true }); + const collision = path.join(harness.targets.agentsDir, "paseo"); + await writeFile(collision, "keep this file"); + + await expect(harness.controller.save({ mode: "custom", skills: ["paseo"] })).rejects.toThrow(); + + expect(await readFile(collision, "utf8")).toBe("keep this file"); + }); + + it("serializes startup convergence with an interactive save", async () => { + await harness.controller.install(); + // Startup finds drift it wants to repair while the user narrows the + // selection. Whichever runs first, disk must end up matching what is saved. + await rm(path.join(harness.targets.claudeDir, "paseo-loop"), { + recursive: true, + force: true, + }); + + const [, saved] = await Promise.all([ + harness.controller.autoUpdate(), + harness.controller.save({ + mode: "custom", + skills: ["paseo"], + confirmedRemovals: ["paseo-advisor", "paseo-loop"], + }), + ]); + + expect(saved.selection).toEqual({ mode: "custom", skills: ["paseo"] }); + expect(await installedEverywhere(harness.targets)).toEqual([["paseo"], ["paseo"], ["paseo"]]); + expect(await harness.controller.status()).toEqual({ + state: "up-to-date", + ops: [], + available: BUNDLED_SKILLS, + installed: ["paseo"], + selection: { mode: "custom", skills: ["paseo"] }, + }); + }); + + it("updates a drifted install without touching the saved selection", async () => { + await harness.controller.save({ mode: "custom", skills: ["paseo"] }); + await writeFile(path.join(harness.targets.agentsDir, "paseo", "SKILL.md"), "stale"); + + expect(await harness.controller.update()).toEqual({ + state: "up-to-date", + ops: [], + available: BUNDLED_SKILLS, + installed: ["paseo"], + selection: { mode: "custom", skills: ["paseo"] }, + }); + }); + + it("does not remove deselected directories during a manual update", async () => { + await harness.controller.save({ mode: "custom", skills: ["paseo"] }); + await writeUserFile(harness.targets, "paseo-loop", "notes/mine.md", "keep this"); + + await harness.controller.update(); + + expect(await readUserFile(harness.targets, "paseo-loop", "notes/mine.md")).toEqual([ + "keep this", + "keep this", + "keep this", + ]); + }); +}); diff --git a/packages/desktop/src/integrations/skills/skills-controller.ts b/packages/desktop/src/integrations/skills/skills-controller.ts new file mode 100644 index 0000000000..7c627bc8d5 --- /dev/null +++ b/packages/desktop/src/integrations/skills/skills-controller.ts @@ -0,0 +1,160 @@ +import { + autoUpdateInstalledSkills, + getSkillsStatus, + installSkills, + type SkillSelection, + type SkillsStatus, + type SkillTargets, + uninstallSkills, + updateSkills, +} from "./operations.js"; +import { + coerceSkillNames, + coerceSkillSelection, + type SkillSelectionStore, +} from "./selection-store.js"; +import { + beginSkillsTransaction, + recoverInterruptedSkillTransactions, +} from "./skills-transaction.js"; + +/** Everything the settings UI needs to render skills in one round trip. */ +export interface SkillsSnapshot extends SkillsStatus { + selection: SkillSelection; +} + +export interface SkillsSaveResult extends SkillsSnapshot { + /** + * Non-null when the save did nothing because these directories would be + * deleted and the request had not confirmed them. Retry with the names. + */ + confirmationRequired: { removals: string[] } | null; +} + +export interface SkillsController { + status(): Promise; + install(): Promise; + update(): Promise; + uninstall(): Promise; + autoUpdate(): Promise; + save(request: unknown): Promise; +} + +type Converge = (targets: SkillTargets, selection: SkillSelection) => Promise; +const MAX_SAVE_ATTEMPTS = 3; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * The single writer for installed skills. Startup convergence and every settings + * command run through one queue, so no operation applies a plan computed from a + * selection another one has already replaced, and no read sees a half-applied + * directory. Ordering between concurrent callers is not promised — consistency + * after both finish is. + */ +export function createSkillsController({ + resolveTargets, + selectionStore, +}: { + // Resolved per operation, not at wiring time: the bundle path depends on how + // the app was packaged, which is not knowable when the controller is built. + resolveTargets: () => SkillTargets; + selectionStore: SkillSelectionStore; +}): SkillsController { + let queue: Promise = Promise.resolve(); + + function serialize(run: () => Promise): Promise { + const next = queue.then(run, run); + queue = next.then( + () => undefined, + () => undefined, + ); + return next; + } + + async function converge(apply: Converge): Promise { + const selection = await selectionStore.get(); + await recoverInterruptedSkillTransactions(resolveTargets(), selection); + return { ...(await apply(resolveTargets(), selection)), selection }; + } + + async function saveSelection(request: unknown): Promise { + const targets = resolveTargets(); + const next = coerceSkillSelection(request); + const previous = await selectionStore.get(); + await recoverInterruptedSkillTransactions(targets, previous); + const confirmed = new Set( + coerceSkillNames(isRecord(request) ? request.confirmedRemovals : null), + ); + + // The plan comes from a scan taken here, inside the queue, and is the exact + // plan applied below. Deciding what will be deleted from an older snapshot + // leaves a window for a directory to appear and be deleted unannounced. + let plan = await getSkillsStatus(targets, next); + for (let attempt = 0; attempt < MAX_SAVE_ATTEMPTS; attempt += 1) { + const removals = plan.ops.filter((op) => op.kind === "delete").map((op) => op.name); + if (removals.some((name) => !confirmed.has(name))) { + return { + ...(await getSkillsStatus(targets, previous)), + selection: previous, + confirmationRequired: { removals }, + }; + } + + // Capture only paths this frozen plan can mutate. Capturing every managed + // path would make rollback delete a new external directory that appeared + // after the scan merely because it was absent from the snapshot. + const transaction = await beginSkillsTransaction(targets, previous, next, plan.ops); + let status: SkillsStatus; + try { + status = await installSkills(targets, next, { + ...plan, + // beginSkillsTransaction atomically staged these directories. Running + // removeSkill afterward would delete a path another writer recreated. + ops: plan.ops.filter((op) => op.kind !== "delete"), + }); + } catch (error) { + await transaction.rollback(); + throw error; + } + + // An empty custom selection converges to the intentional not-installed + // state; zero remaining operations is the invariant, not the label. + if (status.ops.length === 0) { + try { + await selectionStore.set(next); + } catch (error) { + await transaction.rollback(); + throw error; + } + try { + await transaction.commit(); + } catch { + // Selection and disk convergence are already durable. The owned + // manifest stays behind so the next serialized operation retries + // cleanup; reporting this save as failed would misstate its outcome. + } + return { ...status, selection: next, confirmationRequired: null }; + } + + // The frozen plan became incomplete while it was being applied. Put only + // its mutations back, rescan, and either request confirmation for a new + // deletion or retry harmless additions/updates from the fresh plan. + await transaction.rollback(); + plan = await getSkillsStatus(targets, next); + } + + throw new Error("Skills changed repeatedly while the selection was being saved"); + } + + return { + status: () => serialize(() => converge(getSkillsStatus)), + install: () => serialize(() => converge(updateSkills)), + update: () => serialize(() => converge(updateSkills)), + uninstall: () => serialize(() => converge(uninstallSkills)), + autoUpdate: () => serialize(() => converge(autoUpdateInstalledSkills)), + save: (selection) => serialize(() => saveSelection(selection)), + }; +} diff --git a/packages/desktop/src/integrations/skills/skills-transaction.ts b/packages/desktop/src/integrations/skills/skills-transaction.ts new file mode 100644 index 0000000000..cb42039177 --- /dev/null +++ b/packages/desktop/src/integrations/skills/skills-transaction.ts @@ -0,0 +1,607 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + cp, + lstat, + mkdir, + readdir, + readFile, + readlink, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { + listManagedSkillNames, + type SkillOp, + type SkillSelection, + type SkillTargets, +} from "./operations.js"; +import { listFilesRecursive } from "./sync.js"; + +export interface SkillsTransaction { + /** Convergence stuck. Drop the staged copies. */ + commit(): Promise; + /** Put every captured directory back exactly as it was, then drop the staging. */ + rollback(): Promise; +} + +/** + * Written before anything is touched and updated once capture finishes. A + * directory without a readable one of these is not ours and is never deleted — + * the prefix alone is a guess, and guessing here deletes someone's files. + */ +interface TransactionManifest { + owner: typeof MANIFEST_OWNER; + version: 1; + /** `capturing` means convergence never started, so there is nothing to undo. */ + phase: "capturing" | "converging"; + previousSelection: SkillSelection; + nextSelection: SkillSelection; + entries: CapturedDirectory[]; +} + +interface CapturedDirectory { + livePath: string; + kind: SkillOp["kind"]; + /** Relative to the manifest directory, or an absolute same-filesystem delete stage. */ + backupPath: string | null; +} + +const MANIFEST_OWNER = "paseo-skills-transaction"; +const MANIFEST_FILENAME = "transaction.json"; +const TRANSACTION_PREFIX = ".paseo-skills-transaction-"; +const RECOVERED_PREFIX = ".paseo-skills-recovered-"; +const BACKUP_DIRNAME = "backup"; +const MANAGED_FILES_MANIFEST = ".paseo-managed-files.json"; + +async function isDirectory(target: string): Promise { + const info = await stat(target).catch(() => null); + return info?.isDirectory() === true; +} + +function isSkillSelection(value: unknown): value is SkillSelection { + if (typeof value !== "object" || value === null) return false; + const selection = value as Partial; + if (selection.mode === "all") return true; + return ( + selection.mode === "custom" && + Array.isArray(selection.skills) && + selection.skills.every((name) => typeof name === "string") + ); +} + +async function readManifest(transactionDir: string): Promise { + const raw = await readFile(path.join(transactionDir, MANIFEST_FILENAME), "utf8").catch( + () => null, + ); + if (raw === null) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const manifest = parsed as Partial; + if (manifest.owner !== MANIFEST_OWNER || manifest.version !== 1) return null; + if (manifest.phase !== "capturing" && manifest.phase !== "converging") return null; + if (!isSkillSelection(manifest.previousSelection) || !isSkillSelection(manifest.nextSelection)) { + return null; + } + if (!Array.isArray(manifest.entries)) return null; + const validEntries = manifest.entries.every( + (entry) => + typeof entry?.livePath === "string" && + (entry.kind === "add" || entry.kind === "update" || entry.kind === "delete") && + (entry.backupPath === null || typeof entry.backupPath === "string"), + ); + if (!validEntries) return null; + const entries = manifest.entries as CapturedDirectory[]; + return { + owner: MANIFEST_OWNER, + version: 1, + phase: manifest.phase, + previousSelection: manifest.previousSelection, + nextSelection: manifest.nextSelection, + entries, + }; +} + +async function writeManifest(transactionDir: string, manifest: TransactionManifest): Promise { + await writeFile( + path.join(transactionDir, MANIFEST_FILENAME), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8", + ); +} + +function selectionsEqual(left: SkillSelection, right: SkillSelection): boolean { + if (left.mode !== right.mode) return false; + if (left.mode === "all" || right.mode === "all") return true; + return ( + left.skills.length === right.skills.length && + left.skills.every((name, index) => name === right.skills[index]) + ); +} + +async function validateEntries( + targets: SkillTargets, + transactionDir: string, + entries: CapturedDirectory[], +): Promise { + const names = new Set(await listManagedSkillNames(targets.sourceDir)); + const roots = [targets.agentsDir, targets.claudeDir, targets.codexDir]; + return entries.every((entry) => { + const rootIndex = roots.findIndex((root) => path.dirname(entry.livePath) === root); + if (rootIndex === -1) return false; + const name = path.basename(entry.livePath); + if (!names.has(name)) return false; + if (entry.backupPath === null) return true; + const expected = path.join(BACKUP_DIRNAME, String(rootIndex), name); + const sameFilesystemStage = path.join(roots[rootIndex]!, path.basename(transactionDir), name); + if (entry.kind === "delete" && entry.backupPath === sameFilesystemStage) return true; + return ( + entry.backupPath === expected && + path + .resolve(transactionDir, entry.backupPath) + .startsWith(`${path.resolve(transactionDir)}${path.sep}`) + ); + }); +} + +function resolveBackupPath(transactionDir: string, backupPath: string | null): string | null { + if (backupPath === null) return null; + return path.isAbsolute(backupPath) ? backupPath : path.join(transactionDir, backupPath); +} + +async function discardTransaction( + transactionDir: string, + entries: readonly CapturedDirectory[], +): Promise { + const stagingDirs = new Set( + entries.flatMap((entry) => + entry.backupPath !== null && path.isAbsolute(entry.backupPath) + ? [path.dirname(entry.backupPath)] + : [], + ), + ); + for (const stagingDir of stagingDirs) { + await rm(stagingDir, { recursive: true, force: true }); + } + await rm(transactionDir, { recursive: true, force: true }); +} + +async function listAllFiles(rootDir: string): Promise { + const files: string[] = []; + async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) await walk(full); + else if (entry.isFile()) files.push(path.relative(rootDir, full)); + } + } + await walk(rootDir); + return files; +} + +async function expectedSyncedFiles(sourceDir: string, name: string): Promise> { + const skillDir = path.join(sourceDir, name); + const files = new Map(); + const hashes: Record = {}; + for (const rel of await listFilesRecursive(skillDir)) { + const contents = await readFile(path.join(skillDir, rel)); + files.set(rel, contents); + hashes[rel] = createHash("sha256").update(contents).digest("hex"); + } + files.set( + MANAGED_FILES_MANIFEST, + Buffer.from(`${JSON.stringify({ version: 1, files: hashes }, null, 2)}\n`), + ); + return files; +} + +async function copyStagedEntry(source: string, target: string): Promise { + const info = await lstat(source); + await mkdir(path.dirname(target), { recursive: true }); + await cp(source, target, { + recursive: info.isDirectory(), + preserveTimestamps: true, + verbatimSymlinks: true, + }); +} + +async function mergeBackupDirectory(livePath: string, backupPath: string | null): Promise { + if (backupPath === null) return false; + const liveInfo = await lstat(livePath).catch(() => null); + const destination = liveInfo?.isSymbolicLink() ? await realpath(livePath) : livePath; + await mkdir(destination, { recursive: true }); + let hasConflict = false; + + async function mergeMissing(sourceDir: string, destinationDir: string): Promise { + for (const entry of await readdir(sourceDir, { withFileTypes: true })) { + const source = path.join(sourceDir, entry.name); + const target = path.join(destinationDir, entry.name); + const sourceInfo = await lstat(source); + const targetInfo = await lstat(target).catch(() => null); + if (targetInfo === null) { + await copyStagedEntry(source, target); + continue; + } + if ( + sourceInfo.isDirectory() && + !sourceInfo.isSymbolicLink() && + targetInfo.isDirectory() && + !targetInfo.isSymbolicLink() + ) { + if ((sourceInfo.mode & 0o777) !== (targetInfo.mode & 0o777)) hasConflict = true; + await mergeMissing(source, target); + continue; + } + if (sourceInfo.isFile() && targetInfo.isFile()) { + const [sourceContents, targetContents] = await Promise.all([ + readFile(source), + readFile(target), + ]); + if ( + !sourceContents.equals(targetContents) || + (sourceInfo.mode & 0o777) !== (targetInfo.mode & 0o777) + ) { + hasConflict = true; + } + continue; + } + if (sourceInfo.isSymbolicLink() && targetInfo.isSymbolicLink()) { + const [sourceTarget, targetTarget] = await Promise.all([ + readlink(source), + readlink(target), + ]); + if (sourceTarget !== targetTarget) hasConflict = true; + continue; + } + hasConflict = true; + } + } + + await mergeMissing(backupPath, destination); + return hasConflict; +} + +function recoveryBase(transactionDir: string, livePath: string, backupPath: string): string { + const transactionId = path.basename(transactionDir).replace(TRANSACTION_PREFIX, ""); + // Update backups live inside the central transaction directory; delete stages + // live beside their target root. Keep quarantine on the backup's filesystem + // so preserving it remains an atomic rename across mounted agent homes. + const recoveryParent = backupPath.startsWith(`${transactionDir}${path.sep}`) + ? path.dirname(transactionDir) + : path.dirname(livePath); + return path.join( + recoveryParent, + `${RECOVERED_PREFIX}${path.basename(livePath)}-${transactionId}`, + ); +} + +async function findQuarantinedEntry( + transactionDir: string, + livePath: string, + backupPath: string, + claimed: ReadonlySet, +): Promise { + const base = recoveryBase(transactionDir, livePath, backupPath); + const name = path.basename(base); + const matches = (await readdir(path.dirname(base)).catch(() => [])) + .filter((entry) => entry === name || entry.startsWith(`${name}-`)) + .map((entry) => path.join(path.dirname(base), entry)) + .filter((entry) => !claimed.has(entry)) + .sort(); + return matches[0] ?? null; +} + +async function quarantineStagedEntry( + transactionDir: string, + livePath: string, + backupPath: string, +): Promise { + const base = recoveryBase(transactionDir, livePath, backupPath); + let destination = base; + for (let suffix = 1; await lstat(destination).catch(() => null); suffix += 1) { + destination = `${base}-${suffix}`; + } + await rename(backupPath, destination); +} + +async function restoreDeletedDirectory( + transactionDir: string, + livePath: string, + backupPath: string | null, +): Promise { + if (backupPath === null) return; + const liveInfo = await lstat(livePath).catch(() => null); + if (liveInfo === null) { + await mkdir(path.dirname(livePath), { recursive: true }); + await rename(backupPath, livePath); + return; + } + const backupInfo = await lstat(backupPath); + if (backupInfo.isDirectory() && !backupInfo.isSymbolicLink() && (await isDirectory(livePath))) { + const hasConflict = await mergeBackupDirectory(livePath, backupPath); + if (hasConflict) await quarantineStagedEntry(transactionDir, livePath, backupPath); + return; + } + // A concurrent writer recreated the path with an incompatible type. Keep its + // entry live and move the staged original outside transaction cleanup so + // neither side is overwritten and later controller operations can proceed. + await quarantineStagedEntry(transactionDir, livePath, backupPath); +} + +async function restoreStagedEntry( + liveRoot: string, + backupRoot: string | null, + rel: string, +): Promise { + const live = path.join(liveRoot, rel); + const backup = backupRoot && path.join(backupRoot, rel); + await rm(live, { recursive: true, force: true }); + if (backup === null || !(await lstat(backup).catch(() => null))) return; + await copyStagedEntry(backup, live); +} + +async function undoSyncedDirectory( + transactionDir: string, + livePath: string, + backupPath: string | null, + expectedFiles: ReadonlyMap, +): Promise { + for (const rel of await listAllFiles(livePath)) { + const target = path.join(livePath, rel); + const current = await readFile(target).catch(() => null); + const expected = expectedFiles.get(rel); + if (current === null || expected === undefined || !current.equals(expected)) continue; + await restoreStagedEntry(livePath, backupPath, rel); + } + + // Sync can prune files that an older managed-files manifest owned. Restore + // missing staged entries with their type and metadata, but never overwrite a + // path another writer recreated. + const hasConflict = await mergeBackupDirectory(livePath, backupPath); + if (hasConflict && backupPath !== null) { + await quarantineStagedEntry(transactionDir, livePath, backupPath); + } + if ((await readdir(livePath).catch(() => [])).length === 0) { + await rm(livePath, { recursive: true, force: true }); + } +} + +async function restore( + targets: SkillTargets, + transactionDir: string, + manifest: TransactionManifest, +): Promise { + // Nothing was converged yet, so the live tree is already the pre-save state. + if (manifest.phase === "capturing") return; + const expectedByName = new Map>(); + const claimedQuarantines = new Set(); + for (const entry of manifest.entries) { + const backup = resolveBackupPath(transactionDir, entry.backupPath); + if (backup !== null && !(await lstat(backup).catch(() => null))) { + // Atomic rename leaves either the live path or its stage. If neither is + // present, an external actor deleted it; there is nothing this + // transaction can restore. Other captured entries still need rollback. + if (entry.kind === "delete") continue; + const quarantined = await findQuarantinedEntry( + transactionDir, + entry.livePath, + backup, + claimedQuarantines, + ); + if (quarantined !== null) { + claimedQuarantines.add(quarantined); + continue; + } + throw new Error(`Skills transaction backup is missing: ${backup}`); + } + if (entry.kind === "delete") { + await restoreDeletedDirectory(transactionDir, entry.livePath, backup); + continue; + } + const liveInfo = await lstat(entry.livePath).catch(() => null); + if (liveInfo !== null && !(await isDirectory(entry.livePath))) { + // Add/update rollback has the same conflict state as delete rollback. A + // recreated file or file symlink belongs to the external writer; preserve + // it, and preserve any captured original outside transaction cleanup. + if (backup !== null) { + await quarantineStagedEntry(transactionDir, entry.livePath, backup); + } + continue; + } + const backupInfo = backup && (await lstat(backup).catch(() => null)); + if (backupInfo && !backupInfo.isDirectory()) { + if (liveInfo !== null && (await isDirectory(entry.livePath))) { + // The captured file was replaced by a directory after the transaction + // started. Preserve the external tree and quarantine the captured entry + // instead of recursively deleting the replacement during recovery. + await quarantineStagedEntry(transactionDir, entry.livePath, backup); + continue; + } + await restoreStagedEntry(entry.livePath, backup, "."); + continue; + } + const name = path.basename(entry.livePath); + let expected = expectedByName.get(name); + if (!expected) { + expected = await expectedSyncedFiles(targets.sourceDir, name); + expectedByName.set(name, expected); + } + await undoSyncedDirectory(transactionDir, entry.livePath, backup, expected); + } +} + +function transactionParents(targets: SkillTargets): string[] { + return [ + ...new Set( + [targets.agentsDir, targets.claudeDir, targets.codexDir].map((root) => path.dirname(root)), + ), + ]; +} + +/** + * Finishes what an interrupted save started. A transaction directory only exists + * while a save is mid-flight, so finding one means the process died between + * mutating the directories and committing the selection — the staged copies are + * the only place the user's files still exist. Restore them, then drop the + * transaction. Anything without our manifest is left where it is. + */ +export async function recoverInterruptedSkillTransactions( + targets: SkillTargets, + committedSelection: SkillSelection, +): Promise { + for (const parent of transactionParents(targets)) { + const entries = await readdir(parent).catch(() => []); + for (const entry of entries) { + if (!entry.startsWith(TRANSACTION_PREFIX)) continue; + const transactionDir = path.join(parent, entry); + const manifest = await readManifest(transactionDir); + if (!manifest) continue; + if (!(await validateEntries(targets, transactionDir, manifest.entries))) continue; + if (selectionsEqual(committedSelection, manifest.nextSelection)) { + await discardTransaction(transactionDir, manifest.entries); + continue; + } + if (!selectionsEqual(committedSelection, manifest.previousSelection)) { + throw new Error( + `Cannot safely recover interrupted skills transaction at ${transactionDir}`, + ); + } + await restore(targets, transactionDir, manifest); + await discardTransaction(transactionDir, manifest.entries); + } + } +} + +/** + * Captures the managed skill directories so a failed save can put the machine + * back exactly as it was — including files the user added inside them, which + * re-running an install cannot reconstruct. + */ +export async function beginSkillsTransaction( + targets: SkillTargets, + previousSelection: SkillSelection, + nextSelection: SkillSelection, + ops: readonly SkillOp[], +): Promise { + const roots = [targets.agentsDir, targets.claudeDir, targets.codexDir]; + const seenRoots = new Set(); + const uniqueRoots: Array<{ root: string; rootIndex: number }> = []; + for (const [rootIndex, root] of roots.entries()) { + const identity = await realpath(root).catch(() => path.resolve(root)); + if (seenRoots.has(identity)) continue; + seenRoots.add(identity); + uniqueRoots.push({ root, rootIndex }); + } + // Staged next to the skills tree rather than inside it: same filesystem, so a + // restore is a local copy, and never a directory a skill scan walks into. + const transactionDir = path.join(path.dirname(roots[0]!), `${TRANSACTION_PREFIX}${randomUUID()}`); + const entries: CapturedDirectory[] = []; + + async function discard(): Promise { + await discardTransaction(transactionDir, entries); + } + + await mkdir(transactionDir, { recursive: true }); + await writeManifest(transactionDir, { + owner: MANIFEST_OWNER, + version: 1, + phase: "capturing", + previousSelection, + nextSelection, + entries: [], + }); + + try { + for (const { root, rootIndex } of uniqueRoots) { + for (const op of ops) { + const name = op.name; + const livePath = path.join(root, name); + const liveInfo = await lstat(livePath).catch(() => null); + if (liveInfo === null) { + entries.push({ livePath, kind: op.kind, backupPath: null }); + continue; + } + const liveIsDirectory = await isDirectory(livePath); + const backupPath = + op.kind === "delete" + ? path.join(root, path.basename(transactionDir), name) + : path.join(BACKUP_DIRNAME, String(rootIndex), name); + if (op.kind !== "delete") { + const captureSource = + liveInfo.isSymbolicLink() && liveIsDirectory ? await realpath(livePath) : livePath; + const captureInfo = await lstat(captureSource); + await cp(captureSource, resolveBackupPath(transactionDir, backupPath)!, { + recursive: captureInfo.isDirectory(), + preserveTimestamps: true, + verbatimSymlinks: true, + }); + } + entries.push({ livePath, kind: op.kind, backupPath }); + } + } + await writeManifest(transactionDir, { + owner: MANIFEST_OWNER, + version: 1, + phase: "converging", + previousSelection, + nextSelection, + entries, + }); + } catch (error) { + // Nothing has been converged yet, so failing to stage means failing the save + // before it touches anything. + await discard(); + throw error; + } + + try { + // Each delete is staged inside its own skills root, so rename stays atomic + // even when the agent homes are symlinks or mounts on different filesystems. + // Any writer + // holding the old directory keeps writing into the staged inode; any writer + // recreating the old path remains live and is merged on rollback. The later + // convergence plan deliberately omits deletes. + for (const entry of entries) { + if (entry.kind !== "delete" || entry.backupPath === null) continue; + const backup = resolveBackupPath(transactionDir, entry.backupPath)!; + await mkdir(path.dirname(backup), { recursive: true }); + await rename(entry.livePath, backup); + } + } catch (error) { + await restore(targets, transactionDir, { + owner: MANIFEST_OWNER, + version: 1, + phase: "converging", + previousSelection, + nextSelection, + entries, + }); + await discard(); + throw error; + } + + return { + commit: discard, + async rollback(): Promise { + await restore(targets, transactionDir, { + owner: MANIFEST_OWNER, + version: 1, + phase: "converging", + previousSelection, + nextSelection, + entries, + }); + await discard(); + }, + }; +} diff --git a/packages/desktop/src/integrations/skills/sync.test.ts b/packages/desktop/src/integrations/skills/sync.test.ts index fda470fe45..da5b1c8765 100644 --- a/packages/desktop/src/integrations/skills/sync.test.ts +++ b/packages/desktop/src/integrations/skills/sync.test.ts @@ -195,6 +195,39 @@ describe("syncSkills", () => { ); }); + it.skipIf(process.platform === "win32")( + "rejects managed paths that would write through a symlink", + async () => { + await writeBundleSkill(sandbox.sourceDir, "paseo", { + "SKILL.md": "new skill", + "references/guide.md": "new guide", + }); + const onDiskSkill = path.join(sandbox.agentsDir, "paseo"); + const externalReferences = path.join(sandbox.root, "external-references"); + await fs.mkdir(onDiskSkill, { recursive: true }); + await fs.mkdir(externalReferences, { recursive: true }); + await fs.writeFile(path.join(onDiskSkill, "SKILL.md"), "old skill"); + await fs.writeFile(path.join(externalReferences, "guide.md"), "external guide"); + await fs.symlink(externalReferences, path.join(onDiskSkill, "references"), "dir"); + + await expect( + syncSkills({ + sourceDir: sandbox.sourceDir, + agentsDir: sandbox.agentsDir, + claudeDir: sandbox.claudeDir, + codexDir: sandbox.codexDir, + skillNames: ["paseo"], + }), + ).rejects.toThrow("Cannot sync through symbolic link"); + + expect(await fs.readFile(path.join(onDiskSkill, "SKILL.md"), "utf-8")).toBe("old skill"); + expect(await fs.readFile(path.join(externalReferences, "guide.md"), "utf-8")).toBe( + "external guide", + ); + expect((await fs.lstat(path.join(onDiskSkill, "references"))).isSymbolicLink()).toBe(true); + }, + ); + it("reports zero changed files on a no-op resync", async () => { await writeBundleSkill(sandbox.sourceDir, "paseo", { "SKILL.md": "content", diff --git a/packages/desktop/src/integrations/skills/sync.ts b/packages/desktop/src/integrations/skills/sync.ts index 4741101ed8..9233365549 100644 --- a/packages/desktop/src/integrations/skills/sync.ts +++ b/packages/desktop/src/integrations/skills/sync.ts @@ -107,6 +107,31 @@ async function pruneEmptyParentDirs(rootDir: string, rels: readonly string[]): P } } +async function assertManagedPathsStayInsideSkill(rootDir: string, rels: readonly string[]) { + for (const rel of rels) { + const normalized = path.normalize(rel); + if ( + path.isAbsolute(normalized) || + normalized === ".." || + normalized.startsWith(`..${path.sep}`) + ) { + throw new Error(`Cannot sync managed path outside skill directory: ${rel}`); + } + let current = rootDir; + for (const segment of normalized.split(path.sep)) { + current = path.join(current, segment); + const info = await fs.lstat(current).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (info === null) break; + if (info.isSymbolicLink()) { + throw new Error(`Cannot sync through symbolic link: ${current}`); + } + } + } +} + async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise { const files = await listFilesRecursive(srcDir); const srcFileSet = new Set(files); @@ -114,7 +139,13 @@ async function syncDirectoryFiles(srcDir: string, dstDir: string): Promise Date: Fri, 31 Jul 2026 13:03:11 +0200 Subject: [PATCH 27/46] Stop transient file conflict callouts (#2694) * fix(app): prevent transient file conflict callouts Watcher versions are candidates until a matching disk read settles them. The editor now classifies the settled bytes once, preserving dirty buffers and keeping reload content paired with its revision. * fix(app): preserve external BOM changes --- .../app/e2e/file-change-conflicts.spec.ts | 101 ++++++- .../app/e2e/helpers/daemon-websocket-gate.ts | 21 ++ packages/app/e2e/helpers/file-callouts.ts | 48 +++ packages/app/src/file-explorer/read-result.ts | 1 + .../app/src/file-pane/editor/model.test.ts | 273 ++++++++++++++++-- packages/app/src/file-pane/editor/model.ts | 225 ++++++++------- .../editor/observation-source.test.ts | 60 ++++ .../file-pane/editor/observation-source.ts | 50 ++++ packages/app/src/file-pane/live-file/hook.ts | 2 +- .../app/src/file-pane/live-file/model.test.ts | 84 +++++- packages/app/src/file-pane/live-file/model.ts | 41 ++- packages/app/src/file-pane/pane.tsx | 33 +-- packages/app/src/stores/session-store.ts | 1 + 13 files changed, 753 insertions(+), 187 deletions(-) create mode 100644 packages/app/e2e/helpers/file-callouts.ts create mode 100644 packages/app/src/file-pane/editor/observation-source.test.ts create mode 100644 packages/app/src/file-pane/editor/observation-source.ts diff --git a/packages/app/e2e/file-change-conflicts.spec.ts b/packages/app/e2e/file-change-conflicts.spec.ts index 202d273ccc..af031974b2 100644 --- a/packages/app/e2e/file-change-conflicts.spec.ts +++ b/packages/app/e2e/file-change-conflicts.spec.ts @@ -4,6 +4,11 @@ import { expect, test, type Page } from "./fixtures"; import { expectFileTabOpen, openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer"; import type { WithWorkspace } from "./helpers/with-workspace"; import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { + expectFileCalloutWasRendered, + expectNoFileCalloutWasRendered, + recordFileCallouts, +} from "./helpers/file-callouts"; function visibleEditor(page: Page) { return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content"); @@ -61,10 +66,11 @@ async function replaceFileAfterDeletionWasObserved(input: { gate.holdFileReads(relativePath); await unlink(filePath); await gate.waitForHeldFileRead(); + await expect(fileCallout(page)).toHaveCount(0); + gate.releaseHeldFileRead(); await expectOnlyFileCallout(page, "File deleted on disk"); gate.holdNextReadyFileUpdate(relativePath); await writeFile(filePath, content, "utf8"); - gate.releaseHeldFileRead(); await gate.waitForHeldReadyFileUpdate(); await expect.poll(() => readFile(filePath, "utf8")).toBe(content); } @@ -103,7 +109,96 @@ async function expectDirtyConflictCanReload(page: Page): Promise { await expect(fileCallout(page)).toHaveCount(0); } +async function restoreFileAfterWatcherObservedTemporaryAbsence(input: { + gate: Awaited>; + filePath: string; + relativePath: string; +}): Promise { + const parkedPath = `${input.filePath}.saving`; + input.gate.holdFileReads(input.relativePath); + await rename(input.filePath, parkedPath); + await input.gate.waitForFileUpdate(input.relativePath, "missing"); + await input.gate.waitForHeldFileRead(); + await writeFile(input.filePath, await readFile(parkedPath)); + await unlink(parkedPath); + input.gate.releaseHeldFileRead(); +} + test.describe("Workspace file change conflicts", () => { + test("a temporary write gap never renders a disk-change callout", async ({ + page, + withWorkspace, + }) => { + const gate = await installDaemonWebSocketGate(page); + const relativePath = "temporary-gap.ts"; + const filePath = await openTrackedFile(page, withWorkspace, { + prefix: "file-temporary-gap-", + relativePath, + content: "const preserved = true;\n", + }); + await gate.waitForFileSubscription(relativePath); + await recordFileCallouts(page); + + await restoreFileAfterWatcherObservedTemporaryAbsence({ gate, filePath, relativePath }); + await replaceEditorText(page, "const local = true;\n"); + + await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;"); + await expect(fileCallout(page)).toHaveCount(0); + await expectNoFileCalloutWasRendered(page); + }); + + test("an identical atomic rewrite never renders a disk-change callout", async ({ + page, + withWorkspace, + }) => { + const gate = await installDaemonWebSocketGate(page); + const relativePath = "identical-rewrite.ts"; + const content = "const preserved = true;\n"; + const filePath = await openTrackedFile(page, withWorkspace, { + prefix: "file-identical-rewrite-", + relativePath, + content, + }); + await gate.waitForFileSubscription(relativePath); + await recordFileCallouts(page); + + gate.holdNextReadyFileUpdate(relativePath); + await writeFile(`${filePath}.tmp`, content, "utf8"); + await rename(`${filePath}.tmp`, filePath); + await gate.waitForHeldReadyFileUpdate(); + await replaceEditorText(page, "const local = true;\n"); + gate.releaseHeldReadyFileUpdate(); + + await expect.poll(() => readFile(filePath, "utf8")).toContain("const local = true;"); + await expect(fileCallout(page)).toHaveCount(0); + await expectNoFileCalloutWasRendered(page); + }); + + test("a save watcher refresh never replays the pre-save buffer", async ({ + page, + withWorkspace, + }) => { + const gate = await installDaemonWebSocketGate(page); + const relativePath = "save-refresh.ts"; + const filePath = await openTrackedFile(page, withWorkspace, { + prefix: "file-save-refresh-", + relativePath, + content: "const before = true;\n", + }); + await gate.waitForFileSubscription(relativePath); + await recordFileCallouts(page); + gate.holdFileReads(relativePath); + + await replaceEditorText(page, "const saved = true;\n"); + await expect.poll(() => readFile(filePath, "utf8")).toContain("const saved = true;"); + await gate.waitForHeldFileRead(); + + await expect(visibleEditor(page)).toContainText("const saved = true;"); + await expect(fileCallout(page)).toHaveCount(0); + await expectNoFileCalloutWasRendered(page); + gate.releaseHeldFileRead(); + }); + test("a clean file replaced on disk offers one working reload action", async ({ page, withWorkspace, @@ -122,8 +217,8 @@ test.describe("Workspace file change conflicts", () => { relativePath: "inventory.md", content: "# After\n", }); - await expectCleanReplacementCanReload(page); gate.releaseHeldReadyFileUpdate(); + await expectCleanReplacementCanReload(page); }); test("a deleted file shows one explanatory notice and no resolution actions", async ({ @@ -136,8 +231,10 @@ test.describe("Workspace file change conflicts", () => { content: "# Present\n", }); await expect(filePane(page).getByText("Present", { exact: true })).toBeVisible(); + await recordFileCallouts(page); await unlink(filePath); await expectDeletedFileNotice(page); + await expectFileCalloutWasRendered(page, "File deleted on disk"); }); test("a file check error offers a working retry", async ({ page, withWorkspace }) => { diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts index 860dae5be5..ec0d37ea18 100644 --- a/packages/app/e2e/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -76,6 +76,8 @@ export async function installDaemonWebSocketGate(page: Page) { const clientRequestCounts = new Map(); const subscribedFilePaths = new Set(); const fileSubscriptionWaiters = new Map void>(); + const observedFileUpdates = new Set(); + const fileUpdateWaiters = new Map void>(); let fileReadPathToHold: string | null = null; let heldFileReads: Array<() => void> = []; let resolveHeldFileRead: (() => void) | null = null; @@ -84,6 +86,19 @@ export async function installDaemonWebSocketGate(page: Page) { let heldReadyFileUpdate: (() => void) | null = null; let resolveHeldReadyFileUpdate: (() => void) | null = null; let heldReadyFileUpdatePromise = Promise.resolve(); + const recordFileUpdate = (message: ServerMessage): void => { + if ( + message.type !== "fs.file.update" || + typeof message.payload?.version?.status !== "string" || + typeof message.payload.version.path !== "string" + ) { + return; + } + const key = `${message.payload.version.path}:${message.payload.version.status}`; + observedFileUpdates.add(key); + fileUpdateWaiters.get(key)?.(); + fileUpdateWaiters.delete(key); + }; await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { if (!acceptingConnections) { @@ -134,6 +149,7 @@ export async function installDaemonWebSocketGate(page: Page) { fileSubscriptionWaiters.get(path)?.(); fileSubscriptionWaiters.delete(path); } + if (serverMessage) recordFileUpdate(serverMessage); if ( serverMessage?.type === "fs.file.update" && serverMessage.payload?.version?.status === "ready" && @@ -157,6 +173,11 @@ export async function installDaemonWebSocketGate(page: Page) { if (subscribedFilePaths.has(path)) return Promise.resolve(); return new Promise((resolve) => fileSubscriptionWaiters.set(path, resolve)); }, + waitForFileUpdate(path: string, status: "ready" | "missing" | "error"): Promise { + const key = `${path}:${status}`; + if (observedFileUpdates.has(key)) return Promise.resolve(); + return new Promise((resolve) => fileUpdateWaiters.set(key, resolve)); + }, holdFileReads(path: string): void { fileReadPathToHold = path; heldFileReads = []; diff --git a/packages/app/e2e/helpers/file-callouts.ts b/packages/app/e2e/helpers/file-callouts.ts new file mode 100644 index 0000000000..0ca7d6493e --- /dev/null +++ b/packages/app/e2e/helpers/file-callouts.ts @@ -0,0 +1,48 @@ +import type { Page } from "@playwright/test"; +import { expect } from "../fixtures"; + +export async function recordFileCallouts(page: Page): Promise { + await page.evaluate(() => { + const callouts: string[] = []; + const recordAlert = (alert: Element) => { + const text = alert.textContent?.trim(); + if (text && !callouts.includes(text)) callouts.push(text); + }; + const record = (records: MutationRecord[]) => { + for (const mutation of records) { + if ( + !(mutation.target instanceof Element) || + !mutation.target.closest('[data-testid="workspace-file-pane"]') + ) { + continue; + } + for (const node of mutation.addedNodes) { + if (!(node instanceof Element)) continue; + if (node.matches('[role="alert"]')) recordAlert(node); + for (const alert of node.querySelectorAll('[role="alert"]')) recordAlert(alert); + } + } + }; + const observer = new MutationObserver(record); + observer.observe(document.body, { childList: true, subtree: true, characterData: true }); + Object.assign(globalThis, { __PASEO_RECORDED_FILE_CALLOUTS__: callouts }); + }); +} + +export async function expectFileCalloutWasRendered(page: Page, title: string): Promise { + await expect + .poll(() => recordedFileCallouts(page)) + .toEqual(expect.arrayContaining([expect.stringContaining(title)])); +} + +export async function expectNoFileCalloutWasRendered(page: Page): Promise { + await expect.poll(() => recordedFileCallouts(page)).toEqual([]); +} + +function recordedFileCallouts(page: Page): Promise { + return page.evaluate( + () => + (globalThis as typeof globalThis & { __PASEO_RECORDED_FILE_CALLOUTS__?: string[] }) + .__PASEO_RECORDED_FILE_CALLOUTS__ ?? [], + ); +} diff --git a/packages/app/src/file-explorer/read-result.ts b/packages/app/src/file-explorer/read-result.ts index 51b8ee10af..2e36add959 100644 --- a/packages/app/src/file-explorer/read-result.ts +++ b/packages/app/src/file-explorer/read-result.ts @@ -12,6 +12,7 @@ export function explorerFileFromReadResult(file: FileReadResult): ExplorerFile { mimeType: file.mime, size: file.size, modifiedAt: file.modifiedAt, + revision: file.revision, }; } diff --git a/packages/app/src/file-pane/editor/model.test.ts b/packages/app/src/file-pane/editor/model.test.ts index 966537c857..e2f6ea4dbd 100644 --- a/packages/app/src/file-pane/editor/model.test.ts +++ b/packages/app/src/file-pane/editor/model.test.ts @@ -5,7 +5,9 @@ import { getFileConflictCallout, type FileEditorClock, type FileEditorFile, + type FileEditorObservation, type FileEditorSession, + type FileObservationSource, } from "./model"; class TestClock implements FileEditorClock { @@ -38,10 +40,6 @@ class FileSession implements FileEditorSession { this.file = file; } - async read(): Promise { - return this.file; - } - async write(input: { content: string; expectedModifiedAt: string; @@ -71,6 +69,34 @@ class FileSession implements FileEditorSession { } } +class ObservationSource implements FileObservationSource { + observation: FileEditorObservation | null; + refreshes = 0; + private readonly listeners = new Set<() => void>(); + + constructor(observation: FileEditorObservation | null) { + this.observation = observation; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + getObservation(): FileEditorObservation | null { + return this.observation; + } + + refresh = (): void => { + this.refreshes += 1; + }; + + emit(observation: FileEditorObservation): void { + this.observation = observation; + for (const listener of this.listeners) listener(); + } +} + function ready( modifiedAt = "2026-07-18T00:00:00.000Z", size = 3, @@ -94,6 +120,14 @@ function makeModel(input: MakeModelInput = {}) { return { model: new FileEditorModel({ file, session, clock }), session, clock }; } +function observeFile(model: FileEditorModel, file: FileEditorFile): void { + model.receiveFileObservation({ status: "ready", file }); +} + +function observeVersion(model: FileEditorModel, version: FileEditorObservation): void { + model.receiveFileObservation(version); +} + describe("FileEditorModel", () => { test("tracks whether the current buffer differs from persisted content", async () => { const { model } = makeModel(); @@ -112,11 +146,194 @@ describe("FileEditorModel", () => { test("adopts a precise revision for otherwise unchanged initial metadata", () => { const { model } = makeModel(); - model.receiveFileVersion({ ...ready(), revision: "precise-revision" }); + observeFile(model, { + content: "one", + hasBom: false, + version: { ...ready(), revision: "precise-revision" }, + }); expect(model.getSnapshot().observedVersion).toMatchObject({ revision: "precise-revision" }); }); + test("adopts an unchanged disk revision without disturbing a dirty buffer", async () => { + const { model, session } = makeModel(); + model.edit("local"); + observeFile(model, { + content: "one", + hasBom: false, + version: { ...ready(), revision: "replacement-revision" }, + }); + + expect(model.getSnapshot()).toMatchObject({ status: "dirty", content: "local" }); + await model.save(); + + expect(session.writes).toEqual([ + { + content: "local", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + expectedRevision: "replacement-revision", + }, + ]); + }); + + test("adopts an external BOM change before saving a dirty buffer", async () => { + const { model, session } = makeModel(); + model.edit("local"); + observeFile(model, { + content: "one", + hasBom: true, + version: { ...ready(), revision: "replacement-revision" }, + }); + + await model.save(); + + expect(session.writes).toEqual([ + { + content: "\uFEFFlocal", + expectedModifiedAt: "2026-07-18T00:00:00.000Z", + expectedRevision: "replacement-revision", + }, + ]); + }); + + test("conflicts when a same-content observation changes the BOM during a save", async () => { + const { model, session } = makeModel(); + session.holdNextWrite(); + model.edit("saved"); + const save = model.save(); + observeFile(model, { + content: "saved", + hasBom: true, + version: ready("2026-07-18T00:00:02.000Z", 6), + }); + session.finishHeldWrite({ + status: "written", + modifiedAt: "2026-07-18T00:00:01.000Z", + size: 5, + }); + + await save; + + expect(model.getSnapshot()).toMatchObject({ + status: "conflict", + observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" }, + }); + }); + + test("ignores a settled observation that was already consumed", () => { + const { model } = makeModel(); + const observation: FileEditorObservation = { + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }; + let emissions = 0; + model.subscribe(() => { + emissions += 1; + }); + model.receiveFileObservation(observation); + const emissionsAfterFirstDelivery = emissions; + + model.receiveFileObservation(observation); + + expect(emissions).toBe(emissionsAfterFirstDelivery); + }); + + test("does not replay the pre-save observation while its refresh is pending", async () => { + const { model } = makeModel(); + const observation: FileEditorObservation = { + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }; + model.receiveFileObservation(observation); + model.edit("saved"); + await model.save(); + + model.receiveFileObservation(observation); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "saved" }); + }); + + test("does not reload stale bytes after a write conflict", async () => { + const { model, session } = makeModel(); + session.nextWrite = { + status: "conflict", + version: ready("2026-07-18T00:00:02.000Z", 8), + }; + model.edit("important local work"); + await model.save(); + + await model.reload(); + + expect(model.getSnapshot()).toMatchObject({ + status: "conflict", + content: "important local work", + observedVersion: { modifiedAt: "2026-07-18T00:00:02.000Z" }, + }); + }); + + test("reloads a write conflict only after refreshed bytes arrive", async () => { + const { model, session } = makeModel(); + const source = new ObservationSource({ + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }); + model.connectFileObservations(source); + session.nextWrite = { + status: "conflict", + version: ready("2026-07-18T00:00:02.000Z", 4), + }; + model.edit("local"); + await model.save(); + + await model.reload(); + expect(source.refreshes).toBe(1); + expect(model.getSnapshot().content).toBe("local"); + source.emit({ + status: "ready", + file: { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 4), + }, + }); + + expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "disk" }); + }); + + test("abandons a deferred reload when its refresh fails", async () => { + const { model, session } = makeModel(); + const source = new ObservationSource({ + status: "ready", + file: { content: "one", hasBom: false, version: ready() }, + }); + model.connectFileObservations(source); + session.nextWrite = { + status: "conflict", + version: ready("2026-07-18T00:00:02.000Z", 4), + }; + model.edit("local"); + await model.save(); + await model.reload(); + source.emit({ + status: "error", + cwd: "/workspace", + path: "file.ts", + error: "File unavailable.", + }); + model.edit("new local work"); + + source.emit({ + status: "ready", + file: { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:03.000Z", 4), + }, + }); + + expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "new local work" }); + }); + test("keeps a newer edit modified when an older save finishes", async () => { const { model, session } = makeModel(); session.holdNextWrite(); @@ -204,8 +421,7 @@ describe("FileEditorModel", () => { version: ready("2026-07-18T00:00:02.000Z", 8) as Extract, }; - model.receiveFileVersion(session.file.version); - await Promise.resolve(); + observeFile(model, session.file); expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "external" }); }); @@ -218,8 +434,7 @@ describe("FileEditorModel", () => { version: ready("2026-07-18T00:00:02.000Z", 7) as Extract, }; - model.receiveFileVersion(session.file.version); - await Promise.resolve(); + observeFile(model, session.file); expect(model.getSnapshot().lineSeparator).toBe("\n"); model.edit("saved\n"); await model.save(); @@ -230,19 +445,13 @@ describe("FileEditorModel", () => { }); }); - test("coalesces consecutive clean disk updates onto the latest reload", async () => { - const { model, session } = makeModel(); - const reads: Array<(file: FileEditorFile) => void> = []; - session.read = () => new Promise((resolve) => reads.push(resolve)); + test("applies consecutive clean disk observations", () => { + const { model } = makeModel(); const firstVersion = ready("2026-07-18T00:00:02.000Z", 5); const latestVersion = ready("2026-07-18T00:00:03.000Z", 6); - model.receiveFileVersion(firstVersion); - model.receiveFileVersion(latestVersion); - reads[0]?.({ content: "first", hasBom: false, version: firstVersion }); - await Promise.resolve(); - reads[1]?.({ content: "latest", hasBom: false, version: latestVersion }); - await Promise.resolve(); + observeFile(model, { content: "first", hasBom: false, version: firstVersion }); + observeFile(model, { content: "latest", hasBom: false, version: latestVersion }); expect(model.getSnapshot()).toMatchObject({ status: "clean", content: "latest" }); }); @@ -250,7 +459,11 @@ describe("FileEditorModel", () => { test("preserves a dirty buffer and overwrites against the newest disk revision", async () => { const { model, session } = makeModel(); model.edit("local"); - model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4)); + observeFile(model, { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 4), + }); expect(model.getSnapshot()).toMatchObject({ status: "conflict", content: "local" }); await model.overwrite(); @@ -264,7 +477,11 @@ describe("FileEditorModel", () => { test("keeps the local CRLF and BOM when overwriting a conflict", async () => { const { model, session } = makeModel({ content: "one\r\n", hasBom: true }); model.edit("local\r\n"); - model.receiveFileVersion(ready("2026-07-18T00:00:02.000Z", 4)); + observeFile(model, { + content: "disk", + hasBom: false, + version: ready("2026-07-18T00:00:02.000Z", 4), + }); await model.overwrite(); @@ -284,7 +501,7 @@ describe("FileEditorModel", () => { { status: "ready" } >; session.file = { content: "disk", hasBom: false, version: diskVersion }; - model.receiveFileVersion(diskVersion); + observeFile(model, session.file); await model.reload(); @@ -299,7 +516,7 @@ describe("FileEditorModel", () => { { status: "ready" } >; session.file = { content: "disk\n", hasBom: false, version: diskVersion }; - model.receiveFileVersion(diskVersion); + observeFile(model, session.file); await model.reload(); model.edit("saved\n"); @@ -328,7 +545,7 @@ describe("FileEditorModel", () => { test("a deletion conflicts with local changes and stops autosave", () => { const { model, session, clock } = makeModel(); model.edit("local"); - model.receiveFileVersion({ status: "missing", cwd: "/workspace", path: "file.ts" }); + observeVersion(model, { status: "missing", cwd: "/workspace", path: "file.ts" }); clock.fire(); @@ -338,14 +555,14 @@ describe("FileEditorModel", () => { test("clears a transient check error when the recovered file is unchanged", () => { const { model } = makeModel(); - model.receiveFileVersion({ + observeVersion(model, { status: "error", cwd: "/workspace", path: "file.ts", error: "Requested path is not a file", }); - model.receiveFileVersion(ready()); + observeFile(model, { content: "one", hasBom: false, version: ready() }); expect(model.getSnapshot()).toMatchObject({ status: "clean", @@ -358,14 +575,14 @@ describe("FileEditorModel", () => { test("resumes autosave when a dirty file recovers unchanged from a check error", async () => { const { model, session, clock } = makeModel(); model.edit("local"); - model.receiveFileVersion({ + observeVersion(model, { status: "error", cwd: "/workspace", path: "file.ts", error: "Requested path is not a file", }); - model.receiveFileVersion(ready()); + observeFile(model, { content: "one", hasBom: false, version: ready() }); clock.fire(); await Promise.resolve(); diff --git a/packages/app/src/file-pane/editor/model.ts b/packages/app/src/file-pane/editor/model.ts index 79b736899b..6207e28890 100644 --- a/packages/app/src/file-pane/editor/model.ts +++ b/packages/app/src/file-pane/editor/model.ts @@ -1,6 +1,6 @@ import type { FileVersion, FileWriteResult } from "@getpaseo/protocol/messages"; -export type FileEditorStatus = "loading" | "clean" | "dirty" | "saving" | "conflict" | "error"; +export type FileEditorStatus = "clean" | "dirty" | "saving" | "conflict" | "error"; export type FileLineSeparator = "\n" | "\r\n" | "\r"; export interface FileEditorSnapshot { @@ -25,7 +25,6 @@ export interface FileEditorFile { } export interface FileEditorSession { - read(): Promise; write(input: { content: string; expectedModifiedAt: string; @@ -33,11 +32,18 @@ export interface FileEditorSession { }): Promise; } -export interface FileVersionSource { +export type FileEditorObservation = + | { status: "ready"; file: FileEditorFile } + | Extract; + +export interface FileObservationSource { subscribe(listener: () => void): () => void; - getVersion(): FileVersion | null; + getObservation(): FileEditorObservation | null; + refresh(): void; } +type ObservedDiskState = FileEditorObservation | { status: "unsettled"; version: FileVersion }; + export interface FileEditorClock { setTimeout(callback: () => void, delayMs: number): ReturnType; clearTimeout(handle: ReturnType): void; @@ -60,10 +66,14 @@ export class FileEditorModel { private autosave: ReturnType | null = null; private saveSequence = 0; private disposed = false; - private observedWhileSaving: FileVersion | null = null; + private observedWhileSaving: FileEditorObservation | null = null; + private observed: ObservedDiskState; + private lastReceivedObservation: FileEditorObservation | null = null; + private refreshObservation: (() => void) | null = null; + private reloadRequested = false; private persistedContent: string; private hasBom: boolean; - private unsubscribeVersionSource: (() => void) | null = null; + private unsubscribeObservationSource: (() => void) | null = null; constructor(input: { file: FileEditorFile; @@ -74,6 +84,7 @@ export class FileEditorModel { this.clock = input.clock ?? systemClock; this.persistedContent = input.file.content; this.hasBom = input.file.hasBom; + this.observed = { status: "ready", file: input.file }; this.snapshot = { status: "clean", content: input.file.content, @@ -92,26 +103,29 @@ export class FileEditorModel { getSnapshot = (): FileEditorSnapshot => this.snapshot; - connectFileVersions(source: FileVersionSource): void { - this.disconnectFileVersions(); - const receiveVersion = () => { - const version = source.getVersion(); - if (version) this.receiveFileVersion(version); + connectFileObservations(source: FileObservationSource): void { + this.disconnectFileObservations(); + this.refreshObservation = source.refresh; + const receiveObservation = () => { + const observation = source.getObservation(); + if (observation) this.receiveFileObservation(observation); }; - this.unsubscribeVersionSource = source.subscribe(receiveVersion); - receiveVersion(); + this.unsubscribeObservationSource = source.subscribe(receiveObservation); + receiveObservation(); } - disconnectFileVersions(): void { - this.unsubscribeVersionSource?.(); - this.unsubscribeVersionSource = null; + disconnectFileObservations(): void { + this.unsubscribeObservationSource?.(); + this.unsubscribeObservationSource = null; + this.refreshObservation = null; } edit(content: string): void { if (this.disposed || content === this.snapshot.content) return; + this.reloadRequested = false; const modified = content !== this.persistedContent; let status: FileEditorStatus = modified ? "dirty" : "clean"; - if (this.snapshot.status === "conflict" || this.snapshot.status === "loading") { + if (this.snapshot.status === "conflict") { status = "conflict"; } this.setSnapshot({ ...this.snapshot, status, content, modified, error: null }); @@ -130,34 +144,32 @@ export class FileEditorModel { await this.performWrite(this.snapshot.observedVersion); } - receiveFileVersion(version: FileVersion): void { - if (this.disposed) return; - if (sameVersion(version, this.snapshot.observedVersion)) { - if ( - version.status === "ready" && - this.snapshot.observedVersion.status === "ready" && - version.revision && - !this.snapshot.observedVersion.revision - ) { - this.setSnapshot({ - ...this.snapshot, - version: - this.snapshot.version.status === "ready" - ? { ...this.snapshot.version, revision: version.revision } - : this.snapshot.version, - observedVersion: version, - }); - } - return; - } - if (this.restoreUnchangedConflict(version)) return; + receiveFileObservation(observation: FileEditorObservation): void { + if (this.disposed || observation === this.lastReceivedObservation) return; + this.lastReceivedObservation = observation; + const version = observationVersion(observation); + this.observed = observation; this.setSnapshot({ ...this.snapshot, observedVersion: version }); if (this.snapshot.status === "saving") { - this.observedWhileSaving = version; + this.observedWhileSaving = observation; return; } - if (this.snapshot.status === "clean" || this.snapshot.status === "loading") { - void this.reloadFromDisk(version); + if (observation.status !== "ready") { + this.reloadRequested = false; + this.enterConflict(version); + return; + } + if (this.reloadRequested) { + this.reloadRequested = false; + this.applyFile(observation.file); + return; + } + if (observation.file.content === this.persistedContent) { + this.adoptUnchangedFile(observation.file); + return; + } + if (this.snapshot.status === "clean") { + this.applyFile(observation.file); return; } this.enterConflict(version); @@ -171,14 +183,20 @@ export class FileEditorModel { async reload(): Promise { if (this.disposed) return; - await this.reloadFromDisk(this.snapshot.observedVersion); + if (this.observed.status !== "ready") { + this.reloadRequested = true; + this.refreshObservation?.(); + return; + } + this.applyFile(this.observed.file); } dispose(): void { this.disposed = true; + this.reloadRequested = false; this.saveSequence += 1; this.clearAutosave(); - this.disconnectFileVersions(); + this.disconnectFileObservations(); this.listeners.clear(); } @@ -199,9 +217,10 @@ export class FileEditorModel { this.clearAutosave(); const sequence = ++this.saveSequence; const content = this.snapshot.content; + const hasBom = this.hasBom; this.observedWhileSaving = null; this.setSnapshot({ ...this.snapshot, status: "saving", error: null }); - const serializedContent = this.hasBom ? `\uFEFF${content}` : content; + const serializedContent = hasBom ? `\uFEFF${content}` : content; let result: FileWriteResult; try { result = await this.session.write({ @@ -224,11 +243,12 @@ export class FileEditorModel { return; } if (result.status === "conflict") { + this.observed = { status: "unsettled", version: result.version }; this.enterConflict(result.version); return; } - const writtenVersion: FileVersion = { + const writtenVersion: Extract = { status: "ready", cwd: this.snapshot.version.cwd, path: this.snapshot.version.path, @@ -236,64 +256,56 @@ export class FileEditorModel { modifiedAt: result.modifiedAt, revision: result.revision, }; - const pending = this.observedWhileSaving; - this.observedWhileSaving = null; + const pending = this.takeObservedWhileSaving(); this.persistedContent = content; - if (pending && !sameVersion(pending, writtenVersion)) { + if (pending && !observationMatchesWrite(pending, content, hasBom)) { + const pendingVersion = observationVersion(pending); + this.observed = pending; this.setSnapshot({ ...this.snapshot, status: "conflict", modified: this.snapshot.content !== this.persistedContent, version: writtenVersion, - observedVersion: pending, + observedVersion: pendingVersion, error: null, }); return; } + const settledVersion = pending?.status === "ready" ? pending.file.version : writtenVersion; + this.observed = pending ?? { status: "unsettled", version: writtenVersion }; const modified = this.snapshot.content !== this.persistedContent; this.setSnapshot({ ...this.snapshot, status: modified ? "dirty" : "clean", modified, - version: writtenVersion, - observedVersion: writtenVersion, + version: settledVersion, + observedVersion: settledVersion, error: null, }); if (modified) this.scheduleAutosave(); } - private async reloadFromDisk(version: FileVersion): Promise { + private applyFile(file: FileEditorFile): void { this.clearAutosave(); - if (version.status !== "ready") { - this.enterConflict(version); - return; - } - const sequence = ++this.saveSequence; - this.setSnapshot({ ...this.snapshot, status: "loading", error: null }); - try { - const file = await this.session.read(); - if (this.disposed || sequence !== this.saveSequence || this.snapshot.status !== "loading") { - return; - } - this.persistedContent = file.content; - this.hasBom = file.hasBom; - this.setSnapshot({ - status: "clean", - content: file.content, - lineSeparator: detectLineSeparator(file.content), - modified: false, - version: file.version, - observedVersion: file.version, - error: null, - }); - } catch (error) { - if (this.disposed || sequence !== this.saveSequence) return; - this.setSnapshot({ - ...this.snapshot, - status: "error", - error: error instanceof Error ? error.message : String(error), - }); - } + this.saveSequence += 1; + this.persistedContent = file.content; + this.hasBom = file.hasBom; + this.observed = { status: "ready", file }; + this.setSnapshot({ + status: "clean", + content: file.content, + lineSeparator: detectLineSeparator(file.content), + modified: false, + version: file.version, + observedVersion: file.version, + error: null, + }); + } + + private takeObservedWhileSaving(): FileEditorObservation | null { + const observation = this.observedWhileSaving; + this.observedWhileSaving = null; + return observation; } private enterConflict(version: FileVersion): void { @@ -307,27 +319,23 @@ export class FileEditorModel { }); } - private restoreUnchangedConflict(version: FileVersion): boolean { - if ( - this.snapshot.status !== "conflict" || - version.status !== "ready" || - this.snapshot.version.status !== "ready" || - !sameVersion(version, this.snapshot.version) - ) { - return false; - } + private adoptUnchangedFile(file: FileEditorFile): void { + this.hasBom = file.hasBom; + this.observed = { status: "ready", file }; const modified = this.snapshot.content !== this.persistedContent; + const recovering = this.snapshot.status === "conflict"; + let status = this.snapshot.status; + if (recovering) status = modified ? "dirty" : "clean"; this.setSnapshot({ ...this.snapshot, - status: modified ? "dirty" : "clean", + status, modified, - version, - observedVersion: version, - error: null, + version: file.version, + observedVersion: file.version, + error: recovering ? null : this.snapshot.error, }); - if (modified) this.scheduleAutosave(); + if (status === "dirty") this.scheduleAutosave(); else this.clearAutosave(); - return true; } private scheduleAutosave(): void { @@ -377,13 +385,18 @@ function detectLineSeparator(content: string): FileLineSeparator { return "\n"; } -function sameVersion(left: FileVersion, right: FileVersion): boolean { - if (left.status !== right.status || left.cwd !== right.cwd || left.path !== right.path) - return false; - if (left.status === "ready" && right.status === "ready") { - if (left.revision && right.revision) return left.revision === right.revision; - return left.modifiedAt === right.modifiedAt && left.size === right.size; - } - if (left.status === "error" && right.status === "error") return left.error === right.error; - return true; +function observationVersion(observation: FileEditorObservation): FileVersion { + return observation.status === "ready" ? observation.file.version : observation; +} + +function observationMatchesWrite( + observation: FileEditorObservation, + content: string, + hasBom: boolean, +): boolean { + return ( + observation.status === "ready" && + observation.file.content === content && + observation.file.hasBom === hasBom + ); } diff --git a/packages/app/src/file-pane/editor/observation-source.test.ts b/packages/app/src/file-pane/editor/observation-source.test.ts new file mode 100644 index 0000000000..30a751b5c1 --- /dev/null +++ b/packages/app/src/file-pane/editor/observation-source.test.ts @@ -0,0 +1,60 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import { describe, expect, test } from "vitest"; +import type { LiveFileObservation } from "../live-file/model"; +import { createFileObservationSource, type LiveObservationSource } from "./observation-source"; + +class TestLiveObservationSource implements LiveObservationSource { + observation: LiveFileObservation | null; + + constructor(observation: LiveFileObservation | null) { + this.observation = observation; + } + + subscribe(): () => void { + return () => undefined; + } + + getObservation(): LiveFileObservation | null { + return this.observation; + } + + refresh(): void {} +} + +function readyObservation(): LiveFileObservation { + const file: FileReadResult = { + bytes: new TextEncoder().encode("one"), + mime: "text/plain", + size: 3, + path: "file.ts", + kind: "text", + modifiedAt: "2026-07-18T00:00:00.000Z", + revision: "revision-1", + }; + return { + status: "ready", + file, + version: { + status: "ready", + cwd: "/workspace", + path: "file.ts", + size: file.size, + modifiedAt: file.modifiedAt, + revision: file.revision, + }, + }; +} + +describe("createFileObservationSource", () => { + test("returns the same editor observation until the live observation changes", () => { + const liveFile = new TestLiveObservationSource(readyObservation()); + const source = createFileObservationSource(liveFile); + + const first = source.getObservation(); + const repeated = source.getObservation(); + + expect(repeated).toBe(first); + liveFile.observation = readyObservation(); + expect(source.getObservation()).not.toBe(first); + }); +}); diff --git a/packages/app/src/file-pane/editor/observation-source.ts b/packages/app/src/file-pane/editor/observation-source.ts new file mode 100644 index 0000000000..478c5d3e76 --- /dev/null +++ b/packages/app/src/file-pane/editor/observation-source.ts @@ -0,0 +1,50 @@ +import { explorerFileFromReadResult } from "@/file-explorer/read-result"; +import type { LiveFileObservation } from "../live-file/model"; +import type { FileEditorObservation, FileObservationSource } from "./model"; + +export interface LiveObservationSource { + subscribe(listener: () => void): () => void; + getObservation(): LiveFileObservation | null; + refresh(): void; +} + +export function createFileObservationSource( + liveFile: LiveObservationSource, +): FileObservationSource { + let liveObservation: LiveFileObservation | null = null; + let editorObservation: FileEditorObservation | null = null; + return { + subscribe: liveFile.subscribe, + refresh: liveFile.refresh, + getObservation() { + const nextLiveObservation = liveFile.getObservation(); + if (nextLiveObservation === liveObservation) return editorObservation; + liveObservation = nextLiveObservation; + editorObservation = fileEditorObservation(nextLiveObservation); + return editorObservation; + }, + }; +} + +function fileEditorObservation( + observation: LiveFileObservation | null, +): FileEditorObservation | null { + if (!observation || observation.status !== "ready") return observation; + const decodedFile = explorerFileFromReadResult(observation.file); + if (decodedFile.kind !== "text" || decodedFile.content === undefined) { + return { + status: "error", + cwd: observation.version.cwd, + path: observation.version.path, + error: "File is no longer text.", + }; + } + return { + status: "ready", + file: { + content: decodedFile.content, + hasBom: decodedFile.hasBom, + version: observation.version, + }, + }; +} diff --git a/packages/app/src/file-pane/live-file/hook.ts b/packages/app/src/file-pane/live-file/hook.ts index b8b4bf0253..e318f7c0c6 100644 --- a/packages/app/src/file-pane/live-file/hook.ts +++ b/packages/app/src/file-pane/live-file/hook.ts @@ -38,7 +38,7 @@ export function useLiveFile(input: { const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); return { - file: snapshot.file, + file: snapshot.observation?.status === "ready" ? snapshot.observation.file : null, error: snapshot.read.status === "error" ? snapshot.read.error : null, isFetching: snapshot.read.status === "pending", isRetrying: snapshot.read.status === "pending" && snapshot.read.requested, diff --git a/packages/app/src/file-pane/live-file/model.test.ts b/packages/app/src/file-pane/live-file/model.test.ts index d29a7d5f2b..c8b450ed16 100644 --- a/packages/app/src/file-pane/live-file/model.test.ts +++ b/packages/app/src/file-pane/live-file/model.test.ts @@ -117,6 +117,68 @@ async function waitForRead( } describe("LiveFileModel", () => { + it("does not publish a missing candidate when the following read succeeds", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" })); + const publishedStatuses: string[] = []; + model.subscribe(() => { + const observation = model.getObservation(); + if (observation) publishedStatuses.push(observation.status); + }); + + session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); + await waitForRead(session, 2); + session.reads[1].resolve(file()); + await vi.waitFor(() => expect(model.getSnapshot().read).toEqual({ status: "idle" })); + + expect(publishedStatuses).not.toContain("missing"); + }); + + it("publishes missing when the following read also fails", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + session.reads[0].resolve(file()); + await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready")); + + session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); + await waitForRead(session, 2); + session.reads[1].reject(new Error("File not found.")); + + await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing")); + }); + + it("preserves a missing candidate for its queued confirming read", async () => { + const session = new TestLiveFileSession(); + const model = new LiveFileModel(); + model.open({ + session, + target: { cwd: "/workspace", path: "file.ts" }, + liveUpdates: true, + }); + await waitForRead(session, 1); + + session.emit({ status: "missing", cwd: "/workspace", path: "file.ts" }); + session.reads[0].resolve(file()); + await waitForRead(session, 2); + session.reads[1].reject(new Error("File not found.")); + + await vi.waitFor(() => expect(model.getObservation()?.status).toBe("missing")); + }); + it("recovers only from a read begun after a missing observation", async () => { const session = new TestLiveFileSession(); const model = new LiveFileModel(); @@ -127,7 +189,7 @@ describe("LiveFileModel", () => { }); await waitForRead(session, 1); session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getSnapshot().file).toEqual(file())); + await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" })); model.refresh(); await waitForRead(session, 2); @@ -135,14 +197,17 @@ describe("LiveFileModel", () => { session.reads[1].resolve(file("revision-2", "two")); await waitForRead(session, 3); - expect(model.getSnapshot().version?.status).toBe("missing"); + expect(model.getObservation()?.status).toBe("ready"); expect(model.getSnapshot().read).toEqual({ status: "pending", requested: true }); session.reads[2].resolve(file("revision-2", "two")); await vi.waitFor(() => { expect(model.getSnapshot()).toMatchObject({ - version: { status: "ready", revision: "revision-2" }, - file: { revision: "revision-2" }, + observation: { + status: "ready", + version: { revision: "revision-2" }, + file: { revision: "revision-2" }, + }, read: { status: "idle" }, }); }); @@ -158,7 +223,7 @@ describe("LiveFileModel", () => { }); await waitForRead(session, 1); session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getSnapshot().file).toEqual(file())); + await vi.waitFor(() => expect(model.getObservation()).toMatchObject({ status: "ready" })); session.emit({ status: "error", @@ -172,7 +237,7 @@ describe("LiveFileModel", () => { await vi.waitFor(() => { expect(model.getSnapshot()).toMatchObject({ - version: { status: "error", error: "Requested path is not a file" }, + observation: { status: "error", error: "Requested path is not a file" }, read: { status: "error", error: "File unavailable." }, }); }); @@ -195,7 +260,7 @@ describe("LiveFileModel", () => { session.subscription.resolve({ initial: ready("revision-1"), unsubscribe() {} }); await waitForRead(session, 1); - expect(model.getVersion()).toMatchObject({ status: "ready", revision: "revision-2" }); + expect(model.getObservation()).toBeNull(); }); it("retries a failed subscription when the user refreshes", async () => { @@ -210,7 +275,7 @@ describe("LiveFileModel", () => { expect(session.subscribeAttempts).toBe(1); session.reads[0].resolve(file()); - await vi.waitFor(() => expect(model.getVersion()?.status).toBe("ready")); + await vi.waitFor(() => expect(model.getObservation()?.status).toBe("ready")); model.refresh(); await waitForRead(session, 2); @@ -247,8 +312,7 @@ describe("LiveFileModel", () => { await Promise.resolve(); expect(model.getSnapshot()).toEqual({ - file: null, - version: null, + observation: null, read: { status: "idle" }, }); }); diff --git a/packages/app/src/file-pane/live-file/model.ts b/packages/app/src/file-pane/live-file/model.ts index 478742329a..cbd0757490 100644 --- a/packages/app/src/file-pane/live-file/model.ts +++ b/packages/app/src/file-pane/live-file/model.ts @@ -24,15 +24,21 @@ export type LiveFileReadState = | { status: "pending"; requested: boolean } | { status: "error"; error: string }; +export type LiveFileObservation = + | { + status: "ready"; + version: Extract; + file: FileReadResult; + } + | Extract; + export interface LiveFileSnapshot { - file: FileReadResult | null; - version: FileVersion | null; + observation: LiveFileObservation | null; read: LiveFileReadState; } const idleSnapshot: LiveFileSnapshot = { - file: null, - version: null, + observation: null, read: { status: "idle" }, }; @@ -49,6 +55,7 @@ export class LiveFileModel { private activeReadSequence: number | null = null; private activeReadRequested = false; private observedAfterReadSequence: number | null = null; + private candidateVersion: FileVersion | null = null; private refreshQueued = false; private requestedRefreshQueued = false; @@ -59,7 +66,7 @@ export class LiveFileModel { getSnapshot = (): LiveFileSnapshot => this.snapshot; - getVersion = (): FileVersion | null => this.snapshot.version; + getObservation = (): LiveFileObservation | null => this.snapshot.observation; open(input: { session: LiveFileSession; target: LiveFileTarget; liveUpdates: boolean }): void { this.reset(); @@ -111,6 +118,7 @@ export class LiveFileModel { this.activeReadSequence = null; this.activeReadRequested = false; this.observedAfterReadSequence = null; + this.candidateVersion = null; this.refreshQueued = false; this.requestedRefreshQueued = false; this.setSnapshot(idleSnapshot); @@ -144,7 +152,7 @@ export class LiveFileModel { private observeVersion(version: FileVersion): void { this.observedAfterReadSequence = this.issuedReadSequence; - this.setSnapshot({ ...this.snapshot, version }); + this.candidateVersion = version; this.scheduleRead(false); } @@ -180,9 +188,13 @@ export class LiveFileModel { return; } this.observedAfterReadSequence = null; + this.candidateVersion = null; this.setSnapshot({ - file: input.file, - version: readyVersion(input.target, input.file), + observation: { + status: "ready", + file: input.file, + version: readyVersion(input.target, input.file), + }, read: { status: "idle" }, }); this.startQueuedRead(); @@ -203,11 +215,14 @@ export class LiveFileModel { return; } const message = errorMessage(input.error); - let version = this.snapshot.version; - if (version?.status !== "missing" && version?.status !== "error") { - version = { status: "error", cwd: input.target.cwd, path: input.target.path, error: message }; - } - this.setSnapshot({ ...this.snapshot, version, read: { status: "error", error: message } }); + const candidate = this.candidateVersion; + this.candidateVersion = null; + const candidateDescribesFailure = + candidate?.status === "missing" || candidate?.status === "error"; + const observation: LiveFileObservation = candidateDescribesFailure + ? candidate + : { status: "error", cwd: input.target.cwd, path: input.target.path, error: message }; + this.setSnapshot({ ...this.snapshot, observation, read: { status: "error", error: message } }); this.startQueuedRead(); } diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index f042d6628a..ab7e3c35df 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -35,12 +35,8 @@ import { isWeb } from "@/constants/platform"; import { useAppSettings } from "@/hooks/use-settings"; import { useLiveFile } from "./live-file/hook"; import { FilePanelBar } from "./bar"; -import { - FileEditorModel, - getFileConflictCallout, - type FileConflictCallout, - type FileEditorFile, -} from "./editor/model"; +import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; +import { createFileObservationSource } from "./editor/observation-source"; import { FileEditorView } from "./editor/view"; import type { FileConflictAlertState } from "./conflict-alert"; import type { LiveFileModel } from "./live-file/model"; @@ -667,25 +663,6 @@ function EditableFilePane({ const [vimMode, setVimMode] = useState(settings.vimKeybindings ? "NORMAL" : null); const session = useMemo( () => ({ - async read(): Promise { - const file = await client.readFile(cwd, path); - const decodedFile = explorerFileFromReadResult(file); - if (decodedFile.kind !== "text" || decodedFile.content === undefined) { - throw new Error("File is no longer text."); - } - return { - content: decodedFile.content, - hasBom: decodedFile.hasBom, - version: { - status: "ready", - cwd, - path, - size: file.size, - modifiedAt: file.modifiedAt, - revision: file.revision, - }, - }; - }, write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) { return client.writeFile({ cwd, path, ...input }); }, @@ -703,14 +680,16 @@ function EditableFilePane({ path, size: preview.size, modifiedAt: preview.modifiedAt, + revision: preview.revision, }, }, session, }); }); useEffect(() => { - model.connectFileVersions(liveFile); - return () => model.disconnectFileVersions(); + const source = createFileObservationSource(liveFile); + model.connectFileObservations(source); + return () => model.disconnectFileObservations(); }, [liveFile, model]); const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot); const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c0c39293a7..50ad450d60 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -272,6 +272,7 @@ export interface ExplorerFile { mimeType?: string; size: number; modifiedAt: string; + revision?: string; } export interface ExplorerDirectory { From d9b72e1c763a046d45f91f0ef0d56ffc39aec923 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 13:58:08 +0200 Subject: [PATCH 28/46] Use OpenCode busy status for background activity (#2696) * fix(server): ignore OpenCode snapshot diffs when starting autonomous turns OpenCode stamps its filesystem-snapshot diff onto the user message after the turn goes idle. Since #2258 Paseo treats an unknown idle user message.updated as the start of an autonomous turn, so that late metadata write opened a turn no provider-idle boundary would ever close. The agent stayed falsely running until the next submission interrupted it. Reject user message.updated events carrying summary.diffs. Real prompts never carry snapshot diffs, so genuine autonomous turns still start, and filesystem snapshots stay enabled for undo and revert. * fix(server): trust OpenCode busy status for autonomous activity Mutable message records can receive metadata after execution ends. Treat the provider's busy status as the activity boundary and buffer preceding content until that boundary arrives. * test(server): cover autonomous provider activity * fix(server): reconcile OpenCode wakes from busy status # Conflicts: # packages/server/src/server/agent/providers/opencode-agent.ts * fix(server): preserve background activity boundary after stops * fix(server): keep idle events outside background activity Only provider execution status proves a new activity boundary. Reconcile adopted sessions once at subscription time instead of retaining unowned idle events for a future run. * fix(server): order adopted session reconciliation Live runner-status events supersede an in-flight status snapshot. Treat busy and retry as one active provider state. * test(server): remove misleading snapshot gate --------- Co-authored-by: Des Flynn --- .../agent/providers/opencode-agent.test.ts | 248 +++++++++++++++++- .../server/agent/providers/opencode-agent.ts | 138 ++++++---- .../opencode-omo-autonomous.real.e2e.test.ts | 1 + 3 files changed, 327 insertions(+), 60 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index ecc1852b63..b13fe0768b 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1996,7 +1996,7 @@ describe("OpenCode adapter startTurn error handling", () => { } }); - test("streams an autonomous wake as soon as the canceled run reaches its terminal", async () => { + test("streams an autonomous wake after the canceled run reaches its terminal", async () => { const { parent: session, openCode } = await createParentSession("ses_wake_after_terminal"); const settleAbort = createTestDeferred(); const events: AgentStreamEvent[] = []; @@ -2023,6 +2023,13 @@ describe("OpenCode adapter startTurn error handling", () => { type: "session.idle", properties: { sessionID: "ses_wake_after_terminal" }, }); + openCode.emitEvent({ + type: "session.status", + properties: { + sessionID: "ses_wake_after_terminal", + status: { type: "busy" }, + }, + }); for (const event of userMessageEvents({ sessionId: "ses_wake_after_terminal", messageId: "msg_wake_after_terminal", @@ -2137,6 +2144,10 @@ describe("OpenCode adapter startTurn error handling", () => { }, }, }); + openCode.emitEvent({ + type: "session.status", + properties: { sessionID: "ses_deferred_autonomous", status: { type: "busy" } }, + }); openCode.emitEvent({ type: "message.part.updated", properties: { @@ -2372,6 +2383,13 @@ describe("OpenCode adapter startTurn error handling", () => { type: "session.idle", properties: { sessionID: "ses_carried_abort" }, }); + openCode.emitEvent({ + type: "session.status", + properties: { + sessionID: "ses_carried_abort", + status: { type: "busy" }, + }, + }); for (const event of userMessageEvents({ sessionId: "ses_carried_abort", messageId: "msg_wake_between_stops", @@ -3082,7 +3100,9 @@ describe("OpenCode persisted sessions", () => { }); describe("OpenCode provider subagent contract", () => { - async function createAdoptedChildSession(): Promise<{ + async function createAdoptedChildSession( + configureChild?: (client: TestOpenCodeClient) => void, + ): Promise<{ readonly runtime: TestOpenCodeHarness; readonly provider: OpenCodeAgentClient; readonly parent: Awaited>; @@ -3092,6 +3112,7 @@ describe("OpenCode provider subagent contract", () => { const runtime = new TestOpenCodeHarness(); const parentClient = new TestOpenCodeClient(); const childClient = new TestOpenCodeClient(); + configureChild?.(childClient); parentClient.sessionCreateResponse = { data: { id: "ses_parent_external" } }; runtime.enqueueClient(parentClient); runtime.enqueueClient(childClient); @@ -3233,14 +3254,14 @@ describe("OpenCode provider subagent contract", () => { }); for (const event of [ - ...assistantTurnEvents({ sessionId: "ses_child_external", text: "child says hi" }).slice( - 0, - 2, - ), { type: "session.status", properties: { sessionID: "ses_child_external", status: { type: "busy" } }, }, + ...assistantTurnEvents({ sessionId: "ses_child_external", text: "child says hi" }).slice( + 0, + 2, + ), { type: "session.idle", properties: { sessionID: "ses_child_external" } }, ]) { childClient.emitEvent(event); @@ -3272,7 +3293,108 @@ describe("OpenCode provider subagent contract", () => { ); }); - test("surfaces an autonomous OpenCode wake as a new parent turn", async () => { + test.each(["busy", "retry"] as const)( + "reconciles an adopted child that was already %s before attachment", + async (runnerStatus) => { + const { child, childClient, parent } = await createAdoptedChildSession((client) => { + client.sessionStatusResponse = { + data: { ses_child_external: { type: runnerStatus } }, + }; + }); + const completed = createTestDeferred(); + const events: AgentStreamEvent[] = []; + try { + child.subscribe((event) => { + events.push(event); + if (event.type === "turn_completed") { + completed.resolve(); + } + }); + + await vi.waitFor(() => { + expect(events).toContainEqual({ + type: "turn_started", + provider: "opencode", + turnId: "opencode-turn-0", + }); + }); + for (const event of assistantTurnEvents({ + sessionId: "ses_child_external", + text: "already-running child says hi", + })) { + childClient.emitEvent(event); + } + + await completed.promise; + + expect(childClient.calls.sessionStatus).toEqual([{ directory: "/workspace/repo" }]); + expect(turnEventSignatures(events)).toEqual([ + ["turn_started", "opencode-turn-0"], + ["timeline", "opencode-turn-0"], + ["turn_completed", "opencode-turn-0"], + ]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ text: "already-running child says hi" }), + }), + ); + } finally { + await child.close(); + await parent.close(); + } + }, + ); + + test("ignores a stale busy snapshot after the live stream reaches idle", async () => { + const statusStarted = createTestDeferred(); + const releaseStatus = createTestDeferred(); + const { child, childClient, parent } = await createAdoptedChildSession((client) => { + client.sessionStatusImplementation = async () => { + statusStarted.resolve(); + await releaseStatus.promise; + return { data: { ses_child_external: { type: "busy" } } }; + }; + }); + const idleConsumed = createTestDeferred(); + const events: AgentStreamEvent[] = []; + try { + child.subscribe((event) => { + events.push(event); + if (event.type === "provider_subagent") { + idleConsumed.resolve(); + } + }); + + await statusStarted.promise; + childClient.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_child_external" }, + }); + childClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_idle_drain_marker", + parentID: "ses_child_external", + title: "Idle drain marker", + }, + }, + }); + await idleConsumed.promise; + + releaseStatus.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(events.some((event) => event.type === "turn_started")).toBe(false); + } finally { + releaseStatus.resolve(); + await child.close(); + await parent.close(); + } + }); + + test("uses busy as the autonomous boundary without replaying earlier idle content", async () => { const { parent, openCode } = await createParentSession("ses_parent_plugin_wake"); const events: AgentStreamEvent[] = []; parent.subscribe((event) => events.push(event)); @@ -3285,6 +3407,22 @@ describe("OpenCode provider subagent contract", () => { })) { openCode.emitEvent(event); } + openCode.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_plugin_wake_drain_marker", + parentID: "ses_parent_plugin_wake", + title: "Pre-busy drain marker", + }, + }, + }); + await vi.waitFor(() => { + expect(events).toContainEqual(expect.objectContaining({ type: "provider_subagent" })); + }); + expect(events.filter((event) => event.type !== "provider_subagent")).toEqual([]); + events.length = 0; + openCode.emitEvent({ type: "session.status", properties: { sessionID: "ses_parent_plugin_wake", status: { type: "busy" } }, @@ -3300,7 +3438,6 @@ describe("OpenCode provider subagent contract", () => { expect(turnEventSignatures(events)).toEqual([ ["turn_started", "opencode-turn-0"], ["timeline", "opencode-turn-0"], - ["timeline", "opencode-turn-0"], ["turn_completed", "opencode-turn-0"], ]); }); @@ -3313,6 +3450,43 @@ describe("OpenCode provider subagent contract", () => { }), }), ); + expect(events).not.toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + text: "All background tasks are complete.", + }), + }), + ); + } finally { + await parent.close(); + } + }); + + test("starts autonomous activity from busy without requiring an initiating message", async () => { + const { parent, openCode } = await createParentSession("ses_parent_busy_only"); + const events: AgentStreamEvent[] = []; + parent.subscribe((event) => events.push(event)); + + try { + openCode.emitEvent({ + type: "session.status", + properties: { sessionID: "ses_parent_busy_only", status: { type: "busy" } }, + }); + for (const event of assistantTurnEvents({ + sessionId: "ses_parent_busy_only", + text: "Autonomous response after busy.", + })) { + openCode.emitEvent(event); + } + + await vi.waitFor(() => { + expect(turnEventSignatures(events)).toEqual([ + ["turn_started", "opencode-turn-0"], + ["timeline", "opencode-turn-0"], + ["turn_completed", "opencode-turn-0"], + ]); + }); } finally { await parent.close(); } @@ -3378,6 +3552,10 @@ describe("OpenCode provider subagent contract", () => { text: "Autonomous wake", })[0], ); + openCode.emitEvent({ + type: "session.status", + properties: { sessionID: "ses_parent_active_wake", status: { type: "busy" } }, + }); await vi.waitFor(() => { expect(events).toEqual([ { type: "turn_started", provider: "opencode", turnId: "opencode-turn-0" }, @@ -3519,6 +3697,10 @@ describe("OpenCode provider subagent contract", () => { } }); + childClient.emitEvent({ + type: "session.status", + properties: { sessionID: "ses_child_external", status: { type: "busy" } }, + }); childClient.emitEvent({ type: "permission.asked", properties: { @@ -4446,3 +4628,53 @@ function waitForAbort(signal: AbortSignal): Promise { signal.addEventListener("abort", () => resolve(), { once: true }); }); } + +describe("OpenCode snapshot summary false-idle regression", () => { + test("user message.updated carrying snapshot diffs does not start a turn", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_snapshot" } }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession( + { provider: "opencode", cwd: "/workspace/repo" }, + { env: { PASEO_AGENT_ID: "snapshot-agent" } }, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + // Captured live shape: after the turn goes idle OpenCode writes the filesystem + // snapshot diff onto the user message, with no time.end on the message. + openCodeClient.emitEvent({ + type: "message.updated", + properties: { + info: { + id: "msg_snapshot_user", + sessionID: "ses_snapshot", + role: "user", + time: { created: 1000 }, + summary: { + diffs: [{ file: "packages/server/src/thing.ts", additions: 3, deletions: 1 }], + }, + }, + }, + }); + // Drain marker: events are processed in order, so observing this one proves + // the snapshot update above was already handled. + openCodeClient.emitEvent({ + type: "session.created", + properties: { + info: { id: "ses_snapshot_child", parentID: "ses_snapshot", title: "Drain marker" }, + }, + }); + await vi.waitFor(() => { + expect(events).toContainEqual(expect.objectContaining({ type: "provider_subagent" })); + }); + + expect(events.some((event) => event.type === "turn_started")).toBe(false); + await session.close(); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 99c542beaf..672c510dc1 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -2815,15 +2815,28 @@ function getOpenCodeEventSessionId(event: OpenCodeEvent): string | null { ); } -function isOpenCodeTerminalEvent(event: OpenCodeEvent, sessionId: string): boolean { - if (event.type === "session.idle" || event.type === "session.error") { - return event.properties.sessionID === sessionId; +function getOpenCodeRunnerStatusFromEvent( + event: OpenCodeEvent, + sessionId: string, +): OpenCodeRunnerStatus | null { + if (getOpenCodeEventSessionId(event) !== sessionId) { + return null; } - return ( - event.type === "session.status" && - event.properties.sessionID === sessionId && - event.properties.status.type === "idle" - ); + if (event.type === "session.status") { + return event.properties.status.type; + } + if (event.type === "session.idle" || event.type === "session.error") { + return "idle"; + } + return null; +} + +function isOpenCodeRunnerActive(status: OpenCodeRunnerStatus): boolean { + return status === "busy" || status === "retry"; +} + +function isOpenCodeTerminalEvent(event: OpenCodeEvent, sessionId: string): boolean { + return getOpenCodeRunnerStatusFromEvent(event, sessionId) === "idle"; } function isOpenCodeProviderInternalEvent(event: AgentStreamEvent): boolean { @@ -2937,6 +2950,8 @@ class OpenCodeAgentSession implements AgentSession { * run, and a rejection means we never proved the runner stopped. */ private abortSettlement: Promise = Promise.resolve(); + private externalStatusReconciliationStarted = false; + private runnerStatusRevision = 0; private readonly runningToolCalls = new Map(); private subAgentsByCallId = new Map(); private subAgentCallIdByChildSessionId = new Map(); @@ -3393,12 +3408,40 @@ class OpenCodeAgentSession implements AgentSession { } subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.subscribers.add(callback); + this.startExternalStatusReconciliation(); this.startChildSessionHydration(); return () => { this.subscribers.delete(callback); }; } + private startExternalStatusReconciliation(): void { + if (!this.externallyDriven || this.externalStatusReconciliationStarted || this.closed) { + return; + } + this.externalStatusReconciliationStarted = true; + void this.reconcileExternalRunnerStatus().catch((error) => { + this.logger.warn( + { err: error, sessionId: this.sessionId }, + "Failed to reconcile externally driven OpenCode session status", + ); + }); + } + + private async reconcileExternalRunnerStatus(): Promise { + await this.ensureEventStreamReady(); + const observedRevision = this.runnerStatusRevision; + const runnerStatus = await this.readProviderRunnerStatus(); + if ( + this.runnerStatusRevision !== observedRevision || + this.turnState.status !== "idle" || + !isOpenCodeRunnerActive(runnerStatus) + ) { + return; + } + this.startAutonomousTurn(); + } + private startChildSessionHydration(): void { if (this.childHydrationPromise) { return; @@ -3657,21 +3700,8 @@ class OpenCodeAgentSession implements AgentSession { if (!event) { return; } - if ( - this.turnState.status === "stopping" && - getOpenCodeEventSessionId(event) === this.sessionId - ) { - // Residue of the canceled run must not surface as a new turn. Its terminal - // is the authoritative end of the stop, so anything OpenCode publishes - // afterwards belongs to a new run by construction and takes the live path. - if (isOpenCodeTerminalEvent(event, this.sessionId)) { - this.finishStoppingTurn(this.turnState.stop); - } - this.traceOpenCode("provider.opencode.event.skip", { - n: eventCount, - reason: "turn_stopping", - type: event.type, - }); + this.observeRunnerStatusEvent(event); + if (this.discardEventWhileStopping(event, eventCount)) { return; } const translated = await this.translateEvent(event); @@ -3683,7 +3713,7 @@ class OpenCodeAgentSession implements AgentSession { foregroundEvents.push(translatedEvent); } } - if (!turnId && this.shouldStartAutonomousTurn(event, foregroundEvents)) { + if (!turnId && this.shouldStartAutonomousTurn(event)) { turnId = this.startAutonomousTurn(); } if (!turnId) { @@ -3724,6 +3754,27 @@ class OpenCodeAgentSession implements AgentSession { } } + private discardEventWhileStopping(event: OpenCodeEvent, eventCount: number): boolean { + if ( + this.turnState.status !== "stopping" || + getOpenCodeEventSessionId(event) !== this.sessionId + ) { + return false; + } + // Residue of the canceled run must not surface as a new turn. Its terminal + // is the authoritative end of the stop, so anything OpenCode publishes + // afterwards belongs to a new run by construction and takes the live path. + if (isOpenCodeTerminalEvent(event, this.sessionId)) { + this.finishStoppingTurn(this.turnState.stop); + } + this.traceOpenCode("provider.opencode.event.skip", { + n: eventCount, + reason: "turn_stopping", + type: event.type, + }); + return true; + } + private emitBackgroundPermissionRequests(events: readonly AgentStreamEvent[]): void { for (const event of events) { if (event.type === "permission_requested") { @@ -3732,37 +3783,20 @@ class OpenCodeAgentSession implements AgentSession { } } - private shouldStartAutonomousTurn( - event: OpenCodeEvent, - foregroundEvents: readonly AgentStreamEvent[], - ): boolean { + private observeRunnerStatusEvent(event: OpenCodeEvent): void { + if (getOpenCodeRunnerStatusFromEvent(event, this.sessionId) !== null) { + this.runnerStatusRevision += 1; + } + } + + private shouldStartAutonomousTurn(event: OpenCodeEvent): boolean { if (this.turnState.status !== "idle") { return false; } - if (getOpenCodeEventSessionId(event) !== this.sessionId) { - return false; - } - // OpenCode publishes the persisted user message before it marks the runner - // busy. That message is the earliest unambiguous boundary for a plugin- - // initiated parent turn; session metadata and assistant echoes are not. - if (event.type === "message.updated" && event.properties.info.role === "user") { - if (this.emittedUserMessageIds.has(event.properties.info.id)) { - return false; - } - return true; - } - if (!this.externallyDriven) { - return false; - } - if ( - foregroundEvents.some( - (foregroundEvent) => - foregroundEvent.type !== "thread_started" && !toTerminalTurnEvent(foregroundEvent), - ) - ) { - return true; - } - return event.type === "session.status" && event.properties.status.type === "busy"; + // Message records are mutable and can be patched after the runner stops. + // Only OpenCode's execution status is authoritative for autonomous activity. + const runnerStatus = getOpenCodeRunnerStatusFromEvent(event, this.sessionId); + return runnerStatus !== null && isOpenCodeRunnerActive(runnerStatus); } private startAutonomousTurn(): string { diff --git a/packages/server/src/server/daemon-e2e/opencode-omo-autonomous.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-omo-autonomous.real.e2e.test.ts index db3fb44822..a3991cab07 100644 --- a/packages/server/src/server/daemon-e2e/opencode-omo-autonomous.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/opencode-omo-autonomous.real.e2e.test.ts @@ -99,6 +99,7 @@ test("OpenCode remains idle while an OMO child works and completes the autonomou expect(childRunningIndex).toBeGreaterThan(rootRunningIndex); expect(rootIdleIndex).toBeGreaterThan(childRunningIndex); expect(childCompletedIndex).toBeGreaterThan(rootIdleIndex); + expect(wakeStartIndex).toBeGreaterThan(childCompletedIndex); expect(wakeStartIndex).toBeGreaterThan(-1); expect(autonomousTerminalIndex).toBeGreaterThan(wakeStartIndex); expect(cancellationAfterWake).toBe(false); From 87ef631ac48fead7104b310c49c0d01e69eed3e0 Mon Sep 17 00:00:00 2001 From: nikuscs Date: Fri, 31 Jul 2026 13:44:31 +0100 Subject: [PATCH 29/46] Show project icons in status-grouped sidebar rows (#2416) * feat: show project icons in sidebar status view * test(sidebar): keep status icon regression active The ten-second mock can finish during app startup, removing the Working bucket before the icon assertion. Keep the seeded workspace active for the full scenario. --------- Co-authored-by: Mohamed Boudra --- packages/app/e2e/sidebar-model-b.spec.ts | 9 +++ .../src/components/sidebar-workspace-list.tsx | 25 +++--- .../sidebar/sidebar-status-list.tsx | 78 ++++++++++++++++--- .../sidebar/sidebar-subtitle-project-icon.tsx | 74 ++++++++++++++++++ .../sidebar/sidebar-workspace-row-content.tsx | 30 ++++++- .../src/utils/sidebar-project-row-model.ts | 13 ++++ 6 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts index fe57e52481..9eaeeab606 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -122,6 +122,7 @@ test.describe("Model B sidebar shape", () => { repoPrefix: "model-b-status-active-", title: "Working workspace", initialPrompt: "stay busy", + model: "one-minute-stream", }); try { @@ -152,6 +153,14 @@ test.describe("Model B sidebar shape", () => { // the status view. await expect(sidebar.locator('[data-testid^="workspace-tab-"]')).toHaveCount(0); + // Status mode drops the project grouping, so each row leads its subtitle + // with the project's icon to keep projects distinguishable. + for (const workspaceId of [idleProject.workspaceId, activeMock.workspaceId]) { + await expect( + page.getByTestId(`sidebar-row-project-icon-${getServerId()}:${workspaceId}`).first(), + ).toBeVisible({ timeout: 60_000 }); + } + // The busy workspace is grouped under Working, the idle one under Done: // changing one workspace's status moved only that row. const workingRows = page.getByTestId("sidebar-status-group-rows-running"); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 59bc75afd7..cc9ebfb872 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -114,7 +114,7 @@ import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attentio import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel, - resolveSidebarProjectIconTarget, + resolveSidebarProjectIconTargets, resolveSidebarProjectLocalPath, type SidebarProjectHostTarget, } from "@/utils/sidebar-project-row-model"; @@ -1952,6 +1952,16 @@ export function SidebarWorkspaceList({ const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning"); const onToggleWorkspacePin = useSidebarWorkspacePinController(); const showHostLabels = useMemo(() => shouldShowSidebarHostLabels(projects), [projects]); + // Status mode drops the project grouping, so its rows carry their own project + // icon. Project mode fetches the same icons inside ProjectModeList for its + // project headers, so only the active mode requests them. + const statusProjectIconTargets = useMemo( + () => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []), + [groupMode, projects], + ); + const statusProjectIconByProjectKey = useProjectIconDataByProjectKey({ + projects: statusProjectIconTargets, + }); const content = groupMode === "status" ? ( @@ -1960,6 +1970,7 @@ export function SidebarWorkspaceList({ pinnedGroups={pinnedGroups} workspaceEntriesByKey={workspaceEntriesByKey} projectNamesByKey={projectNamesByKey} + projectIconByProjectKey={statusProjectIconByProjectKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} onWorkspacePress={onWorkspacePress} hostLabelByServerId={hostLabelByServerId} @@ -1998,6 +2009,7 @@ function SidebarStatusModeWrapper({ pinnedGroups, workspaceEntriesByKey, projectNamesByKey, + projectIconByProjectKey, shortcutIndexByWorkspaceKey: _projectShortcutIndex, onWorkspacePress, hostLabelByServerId, @@ -2010,6 +2022,7 @@ function SidebarStatusModeWrapper({ pinnedGroups: PinnedSidebarGroups; workspaceEntriesByKey: ReadonlyMap; projectNamesByKey: Map; + projectIconByProjectKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; hostLabelByServerId: ReadonlyMap; @@ -2028,6 +2041,7 @@ function SidebarStatusModeWrapper({ return entry ? [entry] : []; })} projectNamesByKey={projectNamesByKey} + projectIconByProjectKey={projectIconByProjectKey} shortcutIndexByWorkspaceKey={_projectShortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} @@ -2099,14 +2113,7 @@ function ProjectModeList({ canToggle: canTogglePinnedChats, toggleExpanded: togglePinnedChatsExpanded, } = useLimitedSidebarGroup(pinnedChats); - const projectIconTargets = useMemo( - () => - projects.flatMap((project) => { - const target = resolveSidebarProjectIconTarget(project); - return target ? [{ projectKey: project.projectKey, ...target }] : []; - }), - [projects], - ); + const projectIconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]); const nativeScrollGestureProps = useMemo( () => parentGestureRef diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 2b12a5f566..35444cc2ab 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -65,6 +65,7 @@ interface StatusWorkspaceListProps { groups: StatusGroup[]; pinnedWorkspaces: SidebarWorkspaceEntry[]; projectNamesByKey: Map; + projectIconByProjectKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -79,6 +80,7 @@ export function SidebarStatusWorkspaceList({ groups, pinnedWorkspaces, projectNamesByKey, + projectIconByProjectKey, shortcutIndexByWorkspaceKey, showShortcutBadges, onWorkspacePress, @@ -114,11 +116,12 @@ export function SidebarStatusWorkspaceList({ ; projectNamesByKey: Map; + projectIconByProjectKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -210,6 +216,7 @@ function StatusGroupList({ group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} projectNamesByKey={projectNamesByKey} + projectIconByProjectKey={projectIconByProjectKey} shortcutIndex={shortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} @@ -227,6 +234,7 @@ function StatusGroupRows({ group, collapsed, projectNamesByKey, + projectIconByProjectKey, shortcutIndex, showShortcutBadges, onWorkspacePress, @@ -238,6 +246,7 @@ function StatusGroupRows({ group: StatusGroup; collapsed: boolean; projectNamesByKey: Map; + projectIconByProjectKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -265,11 +274,12 @@ function StatusGroupRows({ ; + projectIconByProjectKey: ReadonlyMap; + hostLabelByServerId: ReadonlyMap; + showHostLabels: boolean; +}): StatusRowProjectPresentation { + const projectName = projectNamesByKey.get(workspace.projectKey) ?? ""; + const hostLabel = showHostLabels + ? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId) + : null; + return { + subtitle: buildStatusRowSubtitle({ projectName, hostLabel }), + projectName, + projectIconDataUri: projectIconByProjectKey.get(workspace.projectKey) ?? null, + }; +} + function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) { const [isHovered, setIsHovered] = useState(false); const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore( @@ -391,6 +431,8 @@ function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) { const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ workspace, subtitle, + projectName, + projectIconDataUri, shortcutNumber, showShortcutBadge, canPin, @@ -400,6 +442,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ }: { workspace: SidebarWorkspaceEntry; subtitle: string; + projectName: string; + projectIconDataUri: string | null; shortcutNumber: number | null; showShortcutBadge: boolean; canPin: boolean; @@ -422,6 +466,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + slot: { + width: 12, + // Match the 14px subtitle line so the icon does not change row density. + height: 14, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, + icon: { + width: 12, + height: 12, + borderRadius: theme.borderRadius.sm, + }, + fallback: { + width: 12, + height: 12, + borderRadius: theme.borderRadius.sm, + alignItems: "center", + justifyContent: "center", + }, + fallbackText: { + fontSize: 8, + lineHeight: 12, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index c1784589e9..19794900d6 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -13,6 +13,7 @@ import { Monitor, SquareTerminal, } from "lucide-react-native"; +import { SidebarSubtitleProjectIcon } from "@/components/sidebar/sidebar-subtitle-project-icon"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { SyncedLoader } from "@/components/synced-loader"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; @@ -93,6 +94,8 @@ export function SidebarWorkspaceRowFrame({ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({ workspace, subtitle, + subtitleProjectName = null, + subtitleProjectIconDataUri = null, scriptIconKind = null, isHovered, isLoading, @@ -104,6 +107,9 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte }: { workspace: SidebarWorkspaceEntry; subtitle?: string | null; + /** Project named by the subtitle. Set it to lead the subtitle line with that project's icon. */ + subtitleProjectName?: string | null; + subtitleProjectIconDataUri?: string | null; scriptIconKind?: SidebarWorkspaceScriptIconKind | null; isHovered: boolean; isLoading: boolean; @@ -148,9 +154,19 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte {children} {subtitle ? ( - - {subtitle} - + + {subtitleProjectName ? ( + + ) : null} + + {subtitle} + + ) : null} {workspace.prHint ? ( @@ -567,10 +583,18 @@ const styles = StyleSheet.create((theme) => ({ workspaceBranchTextHovered: { opacity: 1, }, + workspaceSubtitleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + minWidth: 0, + }, workspaceSubtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 14, + flexShrink: 1, + minWidth: 0, }, workspacePrBadgeRow: { flexDirection: "row", diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index d9566198fb..fac2384e14 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -43,6 +43,19 @@ export function resolveSidebarProjectIconTarget( return null; } +export interface SidebarProjectIconTarget extends SidebarProjectHostTarget { + projectKey: string; +} + +export function resolveSidebarProjectIconTargets( + projects: readonly SidebarProjectEntry[], +): SidebarProjectIconTarget[] { + return projects.flatMap((project) => { + const target = resolveSidebarProjectIconTarget(project); + return target ? [{ projectKey: project.projectKey, ...target }] : []; + }); +} + export function resolveSidebarProjectLocalPath( project: SidebarProjectEntry, localServerId: string | null, From 99eca19a76ff7012ab891b619f8a89881f868ef4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 15:42:08 +0200 Subject: [PATCH 30/46] Keep selected projects when switching hosts (#2700) * fix(app): preserve project selection across hosts Rendered project identity and persisted equivalence were conflated after duplicate-project handling. Keep them separate so host changes resolve selectable equivalents deterministically without resetting legacy sidebar identity. * fix(app): restore selected clone after host round trip Keep manual origin and current destination placements separately so switching back restores the chosen checkout while archive gaps preserve the active host selection. * fix(app): key status icons by project view * fix(app): preserve origin during archive gaps --- .../app/e2e/empty-project-persists.spec.ts | 23 +- packages/app/e2e/helpers/new-workspace.ts | 17 +- packages/app/e2e/helpers/project-view-key.ts | 7 + packages/app/e2e/new-workspace-entry.spec.ts | 9 +- ...orkspace-host-project-preservation.spec.ts | 196 ++++++++++ .../app/e2e/schedules-project-target.spec.ts | 17 +- packages/app/e2e/sidebar-model-b.spec.ts | 5 +- packages/app/e2e/sidebar-workspace.spec.ts | 5 +- .../app/e2e/workspace-multiplicity.spec.ts | 13 +- packages/app/src/components/left-sidebar.tsx | 16 +- .../app/src/components/project-icon-view.tsx | 8 +- .../sidebar-workspace-list.test.tsx | 11 +- .../src/components/sidebar-workspace-list.tsx | 115 +++--- .../src/components/sidebar/sidebar-model.tsx | 6 +- .../sidebar/sidebar-projection.test.ts | 6 +- .../components/sidebar/sidebar-projection.ts | 6 +- .../sidebar/sidebar-status-list.tsx | 52 +-- .../sidebar/sidebar-subtitle-project-icon.tsx | 6 +- .../sidebar/sidebar-workspace-row-content.tsx | 2 +- .../hooks/sidebar-status-view-model.test.ts | 23 +- .../src/hooks/sidebar-status-view-model.ts | 10 +- .../sidebar-workspaces-view-model.test.ts | 15 +- .../hooks/sidebar-workspaces-view-model.ts | 41 +- .../app/src/hooks/use-sidebar-pins.test.ts | 4 +- .../hooks/use-sidebar-shortcut-model.test.tsx | 4 +- .../src/hooks/use-sidebar-workspaces-list.ts | 18 +- .../app/src/projects/host-project-model.ts | 102 ++++- .../app/src/projects/host-projects.test.ts | 3 +- packages/app/src/projects/host-projects.ts | 3 + packages/app/src/projects/project-icons.ts | 8 +- .../app/src/projects/project-remove.test.ts | 1 - packages/app/src/projects/project-remove.ts | 1 - .../src/projects/workspace-structure.test.ts | 35 +- .../app/src/projects/workspace-structure.ts | 81 +++- .../src/schedules/schedule-form-model.test.ts | 2 +- .../schedule-project-targets.test.ts | 2 +- .../src/schedules/schedule-project-targets.ts | 10 +- .../use-schedule-form-model.test.tsx | 2 +- .../new-workspace-initial-context.test.ts | 1 + .../screens/new-workspace-initial-context.ts | 32 +- .../app/src/screens/new-workspace-screen.tsx | 44 +-- .../screens/new-workspace/project-picker.ts | 15 +- .../new-workspace/project-selection.test.ts | 362 ++++++++++++++++-- .../new-workspace/project-selection.ts | 146 +++++-- .../src/screens/project-settings-screen.tsx | 18 +- .../app/src/screens/projects-screen.test.tsx | 12 +- packages/app/src/screens/projects-screen.tsx | 29 +- .../session-store-hooks/selectors.test.ts | 27 +- .../stores/session-store-hooks/selectors.ts | 5 +- .../app/src/stores/sidebar-order-store.ts | 28 +- packages/app/src/utils/projects.test.ts | 3 +- packages/app/src/utils/projects.ts | 24 +- .../utils/sidebar-project-row-model.test.ts | 21 +- .../src/utils/sidebar-project-row-model.ts | 4 +- .../app/src/utils/sidebar-shortcuts.test.ts | 4 +- packages/app/src/utils/sidebar-shortcuts.ts | 2 +- 56 files changed, 1234 insertions(+), 428 deletions(-) create mode 100644 packages/app/e2e/helpers/project-view-key.ts create mode 100644 packages/app/e2e/new-workspace-host-project-preservation.spec.ts diff --git a/packages/app/e2e/empty-project-persists.spec.ts b/packages/app/e2e/empty-project-persists.spec.ts index 0fead9d1be..9ce761ab3f 100644 --- a/packages/app/e2e/empty-project-persists.spec.ts +++ b/packages/app/e2e/empty-project-persists.spec.ts @@ -10,6 +10,7 @@ import { import { expectOpenedProject } from "./helpers/project-picker-ui"; import { connectSeedClient, seedWorkspace } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { createTempGitRepo } from "./helpers/workspace"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; @@ -32,12 +33,12 @@ async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Pro await archiveItem.click(); } -async function removeProjectFromSidebar(page: Page, projectKey: string): Promise { - const projectRow = page.getByTestId(`sidebar-project-row-${projectKey}`); +async function removeProjectFromSidebar(page: Page, projectViewKey: string): Promise { + const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`); await expect(projectRow).toBeVisible({ timeout: 30_000 }); await projectRow.hover(); - const kebab = page.getByTestId(`sidebar-project-kebab-${projectKey}`); + const kebab = page.getByTestId(`sidebar-project-kebab-${projectViewKey}`); await expect(kebab).toBeVisible({ timeout: 10_000 }); await kebab.click(); @@ -45,7 +46,7 @@ async function removeProjectFromSidebar(page: Page, projectKey: string): Promise // user-confirmed removal proceeds deterministically. page.once("dialog", (dialog) => void dialog.accept()); - const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectKey}`); + const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectViewKey}`); await expect(removeItem).toBeVisible({ timeout: 10_000 }); await removeItem.click(); } @@ -152,9 +153,10 @@ test.describe("Project with no workspaces persists", () => { const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" }); try { - const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`); + const projectViewKey = projectEquivalenceViewKey(workspace.projectKey); + const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`); const newWorkspaceRow = page.getByTestId( - `sidebar-project-new-workspace-row-${workspace.projectKey}`, + `sidebar-project-new-workspace-row-${projectViewKey}`, ); const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace"); @@ -198,7 +200,8 @@ test.describe("Project remove", () => { let readdedProjectId: string | null = null; try { - const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectKey}`); + const projectViewKey = projectEquivalenceViewKey(workspace.projectKey); + const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`); await gotoAppShell(page); await waitForSidebarHydration(page); @@ -207,7 +210,7 @@ test.describe("Project remove", () => { timeout: 30_000, }); - await removeProjectFromSidebar(page, workspace.projectKey); + await removeProjectFromSidebar(page, projectViewKey); await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, { timeout: 30_000, @@ -233,7 +236,9 @@ test.describe("Project remove", () => { await expect(projectRow).toContainText(workspace.projectDisplayName); await expect(projectRow).not.toContainText(workspace.repoPath); await expect( - page.getByTestId(`sidebar-project-new-workspace-row-${readdedProjectKey}`), + page.getByTestId( + `sidebar-project-new-workspace-row-${projectEquivalenceViewKey(readdedProjectKey)}`, + ), ).toBeVisible({ timeout: 30_000 }); } finally { if (readdedProjectId) { diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index f9245a17c6..7a021fb263 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -3,6 +3,7 @@ import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/inte import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { connectDaemonClient } from "./daemon-client-loader"; import { daemonWsRoutePattern } from "./daemon-port"; +import { projectEquivalenceViewKey } from "./project-view-key"; import { expectWorkspaceHeader } from "./workspace-ui"; type NewWorkspaceDaemonClient = Pick< @@ -167,11 +168,12 @@ export async function openNewWorkspaceComposer( page: Page, input: { projectKey: string; projectDisplayName: string }, ): Promise { - const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first(); + const projectViewKey = projectEquivalenceViewKey(input.projectKey); + const projectRow = page.getByTestId(`sidebar-project-row-${projectViewKey}`).first(); await expect(projectRow).toBeVisible({ timeout: 30_000 }); await projectRow.hover(); - const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first(); + const button = page.getByTestId(`sidebar-project-new-worktree-${projectViewKey}`).first(); await expect(button).toBeVisible({ timeout: 30_000 }); await button.click(); @@ -216,8 +218,10 @@ export async function expectNewWorkspaceDraft(page: Page, draft: string): Promis } export async function selectNewWorkspaceHost(page: Page, hostLabel: string): Promise { - await page.getByTestId("host-picker-trigger").click(); - await page.getByText(hostLabel, { exact: true }).click(); + const trigger = page.getByTestId("host-picker-trigger"); + await trigger.click(); + await page.getByRole("button", { name: hostLabel, exact: true }).click(); + await expect(trigger).toContainText(hostLabel); } export async function submitNewWorkspacePrompt( @@ -244,13 +248,14 @@ export async function clickNewWorkspaceButton( export async function selectNewWorkspaceProject( page: Page, - input: { projectKey: string; projectDisplayName: string }, + input: { projectKey: string; projectDisplayName: string; projectViewKey?: string }, ): Promise { const trigger = page.getByTestId("new-workspace-project-picker-trigger"); await expect(trigger).toBeVisible({ timeout: 30_000 }); await trigger.click(); - const option = page.getByTestId(`new-workspace-project-picker-option-${input.projectKey}`); + const projectViewKey = input.projectViewKey ?? projectEquivalenceViewKey(input.projectKey); + const option = page.getByTestId(`new-workspace-project-picker-option-${projectViewKey}`); await expect(option).toBeVisible({ timeout: 30_000 }); await option.click(); diff --git a/packages/app/e2e/helpers/project-view-key.ts b/packages/app/e2e/helpers/project-view-key.ts new file mode 100644 index 0000000000..1c43d9421f --- /dev/null +++ b/packages/app/e2e/helpers/project-view-key.ts @@ -0,0 +1,7 @@ +export function projectEquivalenceViewKey(projectKey: string): string { + return projectKey; +} + +export function projectPlacementViewKey(serverId: string, projectId: string): string { + return JSON.stringify([serverId, projectId]); +} diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index 78aad7ecec..920b034158 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -11,6 +11,7 @@ import { getE2EDaemonPort } from "./helpers/daemon-port"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { seedSavedSettingsHosts } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; @@ -22,7 +23,7 @@ import { waitForSidebarHydration } from "./helpers/workspace-ui"; // screen, and non-git projects never offer the worktree Isolation control. function projectRow(page: import("@playwright/test").Page, projectKey: string) { - return page.getByTestId(`sidebar-project-row-${projectKey}`); + return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`); } test.describe("New workspace entry points", () => { @@ -195,7 +196,7 @@ test.describe("New workspace entry points", () => { // route-driven navigation targets a different project. await page.getByTestId("new-workspace-project-picker-trigger").click(); const optionC = page.getByTestId( - `new-workspace-project-picker-option-${projectC.projectKey}`, + `new-workspace-project-picker-option-${projectEquivalenceViewKey(projectC.projectKey)}`, ); await expect(optionC).toBeVisible({ timeout: 30_000 }); await optionC.click(); @@ -238,7 +239,7 @@ test.describe("New workspace entry points", () => { await expect(trigger).toBeVisible({ timeout: 30_000 }); await trigger.click(); const nonGitOption = page.getByTestId( - `new-workspace-project-picker-option-${nonGitProject.projectKey}`, + `new-workspace-project-picker-option-${projectEquivalenceViewKey(nonGitProject.projectKey)}`, ); await expect(nonGitOption).toBeVisible({ timeout: 30_000 }); await nonGitOption.click(); @@ -251,7 +252,7 @@ test.describe("New workspace entry points", () => { // Switching to the git project on the same screen reveals the Isolation row. await trigger.click(); const gitOption = page.getByTestId( - `new-workspace-project-picker-option-${gitProject.projectKey}`, + `new-workspace-project-picker-option-${projectEquivalenceViewKey(gitProject.projectKey)}`, ); await expect(gitOption).toBeVisible({ timeout: 30_000 }); await gitOption.click(); diff --git a/packages/app/e2e/new-workspace-host-project-preservation.spec.ts b/packages/app/e2e/new-workspace-host-project-preservation.spec.ts new file mode 100644 index 0000000000..6ee95a900f --- /dev/null +++ b/packages/app/e2e/new-workspace-host-project-preservation.spec.ts @@ -0,0 +1,196 @@ +import { expect, test as base } from "./fixtures"; +import { openProjectDirectoryWithHosts } from "./helpers/project-grouping"; +import { projectPlacementViewKey } from "./helpers/project-view-key"; +import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; +import { + expectNewWorkspaceProjectSelected, + openGlobalNewWorkspaceComposer, + selectNewWorkspaceHost, + selectNewWorkspaceProject, +} from "./helpers/new-workspace"; +import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; +import { createTempGitRepo } from "./helpers/workspace"; + +const PRIMARY_HOST_LABEL = "Primary host"; +const SECONDARY_HOST_LABEL = "Secondary host"; +const SHARED_PROJECT_NAME = "Paseo"; +const SHARED_REMOTE_URL = "https://github.com/getpaseo/paseo.git"; + +interface CreatedProject { + projectId: string; + projectKey: string; + workspaceId: string; +} + +interface CrossHostProjectScenario { + contextWorkspaceId: string; + selectedProjectViewKey: string; + sharedProjectKey: string; + secondaryHost: { + serverId: string; + label: string; + port: number; + }; +} + +async function createProject( + client: SeedDaemonClient, + input: { projectPath: string; projectName: string; workspaceName: string }, +): Promise { + const created = await client.createWorkspace({ + source: { kind: "directory", path: input.projectPath }, + title: input.workspaceName, + }); + if (!created.workspace) { + throw new Error(created.error ?? `Failed to create project ${input.projectName}`); + } + + await client.renameProject(created.workspace.projectId, input.projectName); + const listed = await client.listProjects(); + const project = listed.projects.find( + (candidate) => candidate.projectId === created.workspace?.projectId, + ); + if (!project?.projectKey) { + throw new Error(`Created project ${created.workspace.projectId} has no project key`); + } + + return { + projectId: created.workspace.projectId, + projectKey: project.projectKey, + workspaceId: created.workspace.id, + }; +} + +async function openNewWorkspaceFromContextProject( + page: Parameters[0], + scenario: CrossHostProjectScenario, +): Promise { + await openProjectDirectoryWithHosts(page, { + hosts: [scenario.secondaryHost], + primaryLabel: PRIMARY_HOST_LABEL, + }); + await expect( + page.getByTestId(`sidebar-project-row-${scenario.selectedProjectViewKey}`), + ).toBeVisible(); + await switchWorkspaceViaSidebar({ + page, + serverId: getServerId(), + workspaceId: scenario.contextWorkspaceId, + }); + await openGlobalNewWorkspaceComposer(page); + await expectNewWorkspaceProjectSelected(page, "Context project"); +} + +const test = base.extend<{ crossHostProject: CrossHostProjectScenario }>({ + crossHostProject: async ({ page: _page }, provide) => { + const secondaryDaemon: IsolatedHostDaemon = await startIsolatedHostDaemon( + "new-workspace-project-preservation-secondary", + ); + const contextRepo = await createTempGitRepo("new-workspace-context-"); + const primarySharedRepo = await createTempGitRepo("new-workspace-paseo-primary-", { + originUrl: SHARED_REMOTE_URL, + }); + const primaryDuplicateRepo = await createTempGitRepo("new-workspace-paseo-duplicate-", { + originUrl: SHARED_REMOTE_URL, + }); + const secondaryFallbackRepo = await createTempGitRepo("new-workspace-fallback-"); + const secondarySharedRepo = await createTempGitRepo("new-workspace-paseo-secondary-", { + originUrl: SHARED_REMOTE_URL, + }); + const primaryClient = await connectSeedClient(); + const secondaryClient = await connectSeedClient({ port: secondaryDaemon.port }); + const primaryProjects: CreatedProject[] = []; + const secondaryProjects: CreatedProject[] = []; + + try { + const contextProject = await createProject(primaryClient, { + projectPath: contextRepo.path, + projectName: "Context project", + workspaceName: "Context workspace", + }); + primaryProjects.push(contextProject); + const primarySharedProject = await createProject(primaryClient, { + projectPath: primarySharedRepo.path, + projectName: SHARED_PROJECT_NAME, + workspaceName: "Primary Paseo workspace", + }); + primaryProjects.push(primarySharedProject); + const primaryDuplicateProject = await createProject(primaryClient, { + projectPath: primaryDuplicateRepo.path, + projectName: "Paseo duplicate", + workspaceName: "Duplicate Paseo workspace", + }); + primaryProjects.push(primaryDuplicateProject); + secondaryProjects.push( + await createProject(secondaryClient, { + projectPath: secondaryFallbackRepo.path, + projectName: "Aardvark project", + workspaceName: "Secondary fallback workspace", + }), + ); + const secondarySharedProject = await createProject(secondaryClient, { + projectPath: secondarySharedRepo.path, + projectName: SHARED_PROJECT_NAME, + workspaceName: "Secondary Paseo workspace", + }); + secondaryProjects.push(secondarySharedProject); + + if ( + primarySharedProject.projectKey !== primaryDuplicateProject.projectKey || + primarySharedProject.projectKey !== secondarySharedProject.projectKey + ) { + throw new Error("Expected the Paseo clones to have the same equivalence key"); + } + + await provide({ + contextWorkspaceId: contextProject.workspaceId, + selectedProjectViewKey: projectPlacementViewKey( + getServerId(), + primarySharedProject.projectId, + ), + sharedProjectKey: primarySharedProject.projectKey, + secondaryHost: { + serverId: secondaryDaemon.serverId, + label: SECONDARY_HOST_LABEL, + port: secondaryDaemon.port, + }, + }); + } finally { + for (const project of primaryProjects) { + await primaryClient.removeProject(project.projectId).catch(() => undefined); + } + for (const project of secondaryProjects) { + await secondaryClient.removeProject(project.projectId).catch(() => undefined); + } + await primaryClient.close().catch(() => undefined); + await secondaryClient.close().catch(() => undefined); + await contextRepo.cleanup().catch(() => undefined); + await primarySharedRepo.cleanup().catch(() => undefined); + await primaryDuplicateRepo.cleanup().catch(() => undefined); + await secondaryFallbackRepo.cleanup().catch(() => undefined); + await secondarySharedRepo.cleanup().catch(() => undefined); + await secondaryDaemon.close().catch(() => undefined); + } + }, +}); + +test.describe("New workspace host project preservation", () => { + test.describe.configure({ timeout: 180_000 }); + + test("keeps the equivalent project selected when one host has another clone", async ({ + page, + crossHostProject, + }) => { + await openNewWorkspaceFromContextProject(page, crossHostProject); + await selectNewWorkspaceProject(page, { + projectKey: crossHostProject.sharedProjectKey, + projectViewKey: crossHostProject.selectedProjectViewKey, + projectDisplayName: SHARED_PROJECT_NAME, + }); + await selectNewWorkspaceHost(page, SECONDARY_HOST_LABEL); + + await expectNewWorkspaceProjectSelected(page, SHARED_PROJECT_NAME); + }); +}); diff --git a/packages/app/e2e/schedules-project-target.spec.ts b/packages/app/e2e/schedules-project-target.spec.ts index 64c02ee304..29ea4c2c14 100644 --- a/packages/app/e2e/schedules-project-target.spec.ts +++ b/packages/app/e2e/schedules-project-target.spec.ts @@ -7,6 +7,7 @@ import { } from "./helpers/schedule-fake-host"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { escapeRegex } from "./helpers/regex"; import { expectNoTruncation } from "./helpers/no-truncation"; import { expectSettled, expectStableHeight } from "./helpers/settled"; @@ -205,7 +206,9 @@ test.describe("Schedules project target", () => { await expect(formSheet.getByText("Cron", { exact: true })).toHaveCount(0); await page.getByRole("button", { name: /select project/i }).click(); - await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click(); + await page + .getByTestId(`schedule-project-option-${projectEquivalenceViewKey(workspace.projectKey)}`) + .click(); const projectTrigger = page.getByTestId("schedule-project-trigger"); await expect(projectTrigger).toContainText(workspace.projectDisplayName); await expectSettled(projectTrigger); @@ -315,7 +318,9 @@ test.describe("Schedules project target", () => { await expectSettled(hostTrigger); await projectTrigger.click(); - await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click(); + await page + .getByTestId(`schedule-project-option-${projectEquivalenceViewKey(workspace.projectKey)}`) + .click(); await expect(projectTrigger).toContainText(workspace.projectDisplayName); await expectSettled(projectTrigger); @@ -337,7 +342,9 @@ test.describe("Schedules project target", () => { await expectSettled(projectTrigger); await projectTrigger.click(); - await page.getByTestId(`schedule-project-option-${fakeHost.projectKey}`).click(); + await page + .getByTestId(`schedule-project-option-${projectEquivalenceViewKey(fakeHost.projectKey)}`) + .click(); await expect(projectTrigger).toContainText(fakeHost.projectDisplayName); await expectSettled(projectTrigger); await expect(modelTrigger).toContainText(/select model/i); @@ -363,7 +370,9 @@ test.describe("Schedules project target", () => { await page.getByTestId("schedules-empty-new").click(); await page.getByRole("button", { name: /select project/i }).click(); - await page.getByTestId(`schedule-project-option-${workspace.projectKey}`).click(); + await page + .getByTestId(`schedule-project-option-${projectEquivalenceViewKey(workspace.projectKey)}`) + .click(); await selectModelByLabel(page, "Ten second stream"); await expect( page.getByText("Off keeps each run's workspace in the sidebar for inspection."), diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts index 9eaeeab606..7d5e0fdc1d 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -4,6 +4,7 @@ import { gotoWorkspace, clickNewTerminal } from "./helpers/launcher"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { seedMockAgentWorkspace } from "./helpers/mock-agent"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; @@ -17,11 +18,11 @@ function workspaceRow(page: Page, workspaceId: string) { } function projectRow(page: Page, projectKey: string) { - return page.getByTestId(`sidebar-project-row-${projectKey}`); + return page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(projectKey)}`); } function projectNewWorktreeIcon(page: Page, projectKey: string) { - return page.getByTestId(`sidebar-project-new-worktree-${projectKey}`); + return page.getByTestId(`sidebar-project-new-worktree-${projectEquivalenceViewKey(projectKey)}`); } async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise { diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 7494fa2394..69c86af848 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -11,6 +11,7 @@ import { import { seedWorkspace } from "./helpers/seed-client"; import { expectWorkspaceHeader } from "./helpers/workspace-ui"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { escapeRegex } from "./helpers/regex"; const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git"; @@ -219,7 +220,9 @@ test.describe("Half-screen desktop layout", () => { } await gotoAppShell(page); - await page.getByTestId(`sidebar-project-show-more-${workspace.projectKey}`).click(); + await page + .getByTestId(`sidebar-project-show-more-${projectEquivalenceViewKey(workspace.projectKey)}`) + .click(); await waitForSidebarWorkspace(page, lastWorkspaceId); const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll"); diff --git a/packages/app/e2e/workspace-multiplicity.spec.ts b/packages/app/e2e/workspace-multiplicity.spec.ts index bafc4407ad..ba7abe5495 100644 --- a/packages/app/e2e/workspace-multiplicity.spec.ts +++ b/packages/app/e2e/workspace-multiplicity.spec.ts @@ -12,6 +12,7 @@ import { import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; import { expectExplorerEntryVisible } from "./helpers/file-explorer"; import { getServerId } from "./helpers/server-id"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; // Model B reshape: a workspace is the unit, its isolation (local checkout or @@ -190,9 +191,9 @@ test.describe("Workspace multiplicity creation flow", () => { await waitForSidebarHydration(page); // Model B: a non-git project is an expandable parent like any other, with // its single workspace already rendered as its own row underneath. - await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ - timeout: 30_000, - }); + await expect( + page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`), + ).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ timeout: 30_000, }); @@ -210,9 +211,9 @@ test.describe("Workspace multiplicity creation flow", () => { // Both the original and the new workspace render as distinct rows under // the same expandable parent. - await expect(page.getByTestId(`sidebar-project-row-${seeded.projectKey}`)).toBeVisible({ - timeout: 30_000, - }); + await expect( + page.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`), + ).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId(workspaceRowTestId(seeded.workspaceId))).toBeVisible({ timeout: 30_000, }); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 9fc43b44e7..d64a695b8c 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -77,14 +77,14 @@ interface SidebarSharedProps { pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; + projectNamesByViewKey: Map; isInitialLoad: boolean; isRevalidating: boolean; isManualRefresh: boolean; groupMode: SidebarGroupMode; collapsedProjectKeys: ReadonlySet; shortcutIndexByWorkspaceKey: Map; - toggleProjectCollapsed: (projectKey: string) => void; + toggleProjectCollapsed: (projectViewKey: string) => void; handleRefresh: () => void; handleOpenProject: () => void; handleHome: () => void; @@ -132,7 +132,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole const { projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, isInitialLoad, isRevalidating, refreshAll, @@ -238,7 +238,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole pinnedGroups, projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, isInitialLoad, isRevalidating, isManualRefresh, @@ -592,7 +592,7 @@ function MobileSidebar({ pinnedGroups, projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, isInitialLoad, isRevalidating, isManualRefresh, @@ -708,7 +708,7 @@ function MobileSidebar({ pinnedGroups={pinnedGroups} projects={projects} workspaceEntriesByKey={workspaceEntriesByKey} - projectNamesByKey={projectNamesByKey} + projectNamesByViewKey={projectNamesByViewKey} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} onWorkspacePress={handleWorkspacePress} @@ -738,7 +738,7 @@ function DesktopSidebar({ pinnedGroups, projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, isInitialLoad, isRevalidating, isManualRefresh, @@ -875,7 +875,7 @@ function DesktopSidebar({ pinnedGroups={pinnedGroups} projects={projects} workspaceEntriesByKey={workspaceEntriesByKey} - projectNamesByKey={projectNamesByKey} + projectNamesByViewKey={projectNamesByViewKey} isRefreshing={isManualRefresh && isRevalidating} onRefresh={handleRefresh} onAddProject={handleOpenProject} diff --git a/packages/app/src/components/project-icon-view.tsx b/packages/app/src/components/project-icon-view.tsx index 95bc886cbf..3d2444b11b 100644 --- a/packages/app/src/components/project-icon-view.tsx +++ b/packages/app/src/components/project-icon-view.tsx @@ -15,22 +15,22 @@ const WHITE_TEXT = { color: "#ffffff" } as const; export function ProjectIconView({ iconDataUri, initial, - projectKey, + projectViewKey, imageStyle, fallbackStyle, textStyle, }: { iconDataUri: string | null; initial: string; - projectKey: string; + projectViewKey: string; imageStyle: StyleProp; fallbackStyle: StyleProp; textStyle: StyleProp; }) { const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); const fallbackStyles = useMemo( - () => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectKey) }], - [fallbackStyle, projectKey], + () => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectViewKey) }], + [fallbackStyle, projectViewKey], ); const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index 972d46bc18..3773f86291 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRoot, type Root } from "react-dom/client"; import React from "react"; import type { ReactElement } from "react"; +import { createProjectViewKey } from "@/projects/workspace-structure"; vi.hoisted(() => { (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; @@ -196,7 +197,7 @@ function ProjectHeaderProbe({ project: SidebarProjectEntry; counts: RenderCounts; }): null { - incrementRecord(counts.headers, project.projectKey); + incrementRecord(counts.headers, project.viewKey); return null; } @@ -232,7 +233,7 @@ function ProjectActiveProbe({ activeSelection?.serverId === serverId && project.workspaces.some((entry) => entry.workspaceId === activeSelection.workspaceId); void isActive; - incrementRecord(counts.projectSelection, project.projectKey); + incrementRecord(counts.projectSelection, project.viewKey); return null; } @@ -260,7 +261,7 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement { return ( <> {projects.map((project) => ( -
+
{project.workspaces.map((entry) => ( @@ -453,8 +454,8 @@ describe("sidebar workspace render isolation", () => { expect(counts.frame).toBe(1); expect(counts.projectSelection).toEqual({ - "project-a": 1, - "project-b": 1, + [createProjectViewKey({ kind: "equivalence", projectKey: "project-a" })]: 1, + [createProjectViewKey({ kind: "equivalence", projectKey: "project-b" })]: 1, }); expect(counts.rowSelection).toEqual({ "a-main": 1, diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index cc9ebfb872..28f94e8230 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -57,7 +57,7 @@ import { import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useHostFeatureMap } from "@/runtime/host-features"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; import { buildNewWorkspaceRoute, buildProjectSettingsRoute, @@ -137,7 +137,7 @@ import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; -const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; +const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKey; const WORKSPACE_STATUS_DOT_WIDTH = 14; const DEFAULT_STATUS_DOT_SIZE = 7; @@ -234,9 +234,9 @@ interface SidebarWorkspaceListProps { pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; + projectNamesByViewKey: Map; collapsedProjectKeys: ReadonlySet; - onToggleProjectCollapsed: (projectKey: string) => void; + onToggleProjectCollapsed: (projectViewKey: string) => void; shortcutIndexByWorkspaceKey: Map; groupMode: "project" | "status"; isRefreshing?: boolean; @@ -431,7 +431,7 @@ function ProjectLeadingVisual({ displayName, iconDataUri, workspace, - projectKey, + projectViewKey, chevron = null, showChevron = false, isArchiving = false, @@ -439,7 +439,7 @@ function ProjectLeadingVisual({ displayName: string; iconDataUri: string | null; workspace: SidebarWorkspaceEntry | null; - projectKey: string; + projectViewKey: string; chevron?: "expand" | "collapse" | null; showChevron?: boolean; isArchiving?: boolean; @@ -467,7 +467,7 @@ function ProjectLeadingVisual({ ); @@ -477,7 +477,7 @@ function ProjectLeadingVisual({ ) : null} {onRemoveProject ? ( @@ -527,7 +527,7 @@ function ProjectRowTrailingActions({ pointerEvents={actionsVisible ? "auto" : "none"} > void; @@ -595,14 +595,14 @@ function ProjectKebabMenu({ style={projectKebabStyle} accessibilityRole={platformIsWeb ? undefined : "button"} accessibilityLabel={t("sidebar.project.actions.menu")} - testID={`sidebar-project-kebab-${projectKey}`} + testID={`sidebar-project-kebab-${projectViewKey}`} > {renderKebabTriggerIcon} {canOpenProjectSettings ? ( @@ -611,7 +611,7 @@ function ProjectKebabMenu({ ) : null} {canOpenInNewWindow ? ( @@ -620,10 +620,10 @@ function ProjectKebabMenu({ ) : null} {dotColorStyle ? ( {({ hovered, pressed }) => ( <> @@ -1040,7 +1040,7 @@ function ProjectHeaderRow({ displayName={displayName} iconDataUri={iconDataUri} workspace={workspace} - projectKey={project.projectKey} + projectViewKey={project.viewKey} chevron={chevron} showChevron={isHovered && chevron !== null} isArchiving={isArchiving} @@ -1088,7 +1088,7 @@ function ProjectHeaderRow({ onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} - testID={`sidebar-project-row-${project.projectKey}`} + testID={`sidebar-project-row-${project.viewKey}`} > {rowChildren} @@ -1111,7 +1111,7 @@ function ProjectHeaderRow({ onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} - testID={`sidebar-project-row-${project.projectKey}`} + testID={`sidebar-project-row-${project.viewKey}`} > {rowChildren} @@ -1622,9 +1622,9 @@ function ProjectBlock({ showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; parentGestureRef?: MutableRefObject; - onToggleCollapsed: (projectKey: string) => void; + onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; - onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspacePlacement[]) => void; + onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; @@ -1725,9 +1725,9 @@ function ProjectBlock({ const handleWorkspaceDragEnd = useCallback( (workspaces: SidebarWorkspacePlacement[]) => { - onWorkspaceReorder(project.projectKey, workspaces); + onWorkspaceReorder(project.viewKey, workspaces); }, - [onWorkspaceReorder, project.projectKey], + [onWorkspaceReorder, project.viewKey], ); const toast = useToast(); @@ -1753,7 +1753,6 @@ function ProjectBlock({ setIsRemovingProject(true); const readiness = getCurrentProjectRemoveReadiness({ - projectKey: project.projectKey, hosts: project.hosts, }); if (readiness.kind === "needs_host_update") { @@ -1785,11 +1784,11 @@ function ProjectBlock({ setIsRemovingProject(false); }); })(); - }, [isRemovingProject, displayName, t, toast, project.projectKey, project.hosts]); + }, [isRemovingProject, displayName, t, toast, project.hosts]); const handleToggleCollapsed = useCallback(() => { - onToggleCollapsed(project.projectKey); - }, [onToggleCollapsed, project.projectKey]); + onToggleCollapsed(project.viewKey); + }, [onToggleCollapsed, project.viewKey]); let projectChildren = null; if (!collapsed) { @@ -1797,7 +1796,7 @@ function ProjectBlock({ projectChildren = ( <> ) : null} @@ -1925,7 +1924,7 @@ export function SidebarWorkspaceList({ pinnedGroups, projects, workspaceEntriesByKey, - projectNamesByKey, + projectNamesByViewKey, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, @@ -1959,7 +1958,7 @@ export function SidebarWorkspaceList({ () => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []), [groupMode, projects], ); - const statusProjectIconByProjectKey = useProjectIconDataByProjectKey({ + const statusProjectIconByProjectViewKey = useProjectIconDataByProjectViewKey({ projects: statusProjectIconTargets, }); @@ -1969,8 +1968,8 @@ export function SidebarWorkspaceList({ statusGroups={statusGroups} pinnedGroups={pinnedGroups} workspaceEntriesByKey={workspaceEntriesByKey} - projectNamesByKey={projectNamesByKey} - projectIconByProjectKey={statusProjectIconByProjectKey} + projectNamesByViewKey={projectNamesByViewKey} + projectIconByProjectViewKey={statusProjectIconByProjectViewKey} shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} onWorkspacePress={onWorkspacePress} hostLabelByServerId={hostLabelByServerId} @@ -2008,8 +2007,8 @@ function SidebarStatusModeWrapper({ statusGroups, pinnedGroups, workspaceEntriesByKey, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, shortcutIndexByWorkspaceKey: _projectShortcutIndex, onWorkspacePress, hostLabelByServerId, @@ -2021,8 +2020,8 @@ function SidebarStatusModeWrapper({ statusGroups: StatusGroup[]; pinnedGroups: PinnedSidebarGroups; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; hostLabelByServerId: ReadonlyMap; @@ -2040,8 +2039,8 @@ function SidebarStatusModeWrapper({ const entry = workspaceEntriesByKey.get(workspace.workspaceKey); return entry ? [entry] : []; })} - projectNamesByKey={projectNamesByKey} - projectIconByProjectKey={projectIconByProjectKey} + projectNamesByViewKey={projectNamesByViewKey} + projectIconByProjectViewKey={projectIconByProjectViewKey} shortcutIndexByWorkspaceKey={_projectShortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} @@ -2074,7 +2073,7 @@ function ProjectModeList({ onToggleWorkspacePin, }: Omit< SidebarWorkspaceListProps, - "statusGroups" | "projectNamesByKey" | "groupMode" | "isRefreshing" | "onRefresh" + "statusGroups" | "projectNamesByViewKey" | "groupMode" | "isRefreshing" | "onRefresh" > & { pathname: string; hostLabelByServerId: ReadonlyMap; @@ -2128,7 +2127,7 @@ function ProjectModeList({ [parentGestureRef], ); - const projectIconByProjectKey = useProjectIconDataByProjectKey({ + const projectIconByProjectViewKey = useProjectIconDataByProjectViewKey({ projects: projectIconTargets, }); @@ -2180,7 +2179,7 @@ function ProjectModeList({ const handleProjectDragEnd = useCallback( (reorderedProjects: SidebarProjectEntry[]) => { - const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey); + const reorderedProjectKeys = reorderedProjects.map((project) => project.viewKey); const currentProjectOrder = getProjectOrder(); if ( !hasVisibleOrderChanged({ @@ -2202,9 +2201,9 @@ function ProjectModeList({ ); const handleWorkspaceReorder = useCallback( - (projectKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { + (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); - const currentWorkspaceOrder = getWorkspaceOrder(projectKey); + const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); if ( !hasVisibleOrderChanged({ currentOrder: currentWorkspaceOrder, @@ -2215,7 +2214,7 @@ function ProjectModeList({ } setWorkspaceOrder( - projectKey, + projectViewKey, mergeWithRemainder({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, @@ -2262,12 +2261,12 @@ function ProjectModeList({ ) => { return ( ; - toggleProjectCollapsed: (projectKey: string) => void; + toggleProjectCollapsed: (projectViewKey: string) => void; shortcutModel: SidebarShortcutModel; } @@ -59,7 +59,7 @@ export function SidebarModelProvider({ projects: list.projects, pinnedKeys, workspaceEntriesByKey: projectionWorkspaceEntriesByKey, - projectNamesByKey: list.projectNamesByKey, + projectNamesByViewKey: list.projectNamesByViewKey, groupMode, pinnedCollapsed, collapsedProjectKeys, @@ -69,7 +69,7 @@ export function SidebarModelProvider({ collapsedProjectKeys, collapsedStatusGroupKeys, groupMode, - list.projectNamesByKey, + list.projectNamesByViewKey, list.projects, pinnedCollapsed, pinnedKeys, diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index 2c2856abc0..d456efa3b3 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -11,7 +11,7 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu workspaceKey: `srv:${id}`, serverId: "srv", workspaceId: id, - projectKey: "project", + projectViewKey: "project", projectName: "Project", projectKind: "git", workspaceKind: "worktree", @@ -36,7 +36,7 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { return { - projectKey: "project", + viewKey: "project", projectName: "Project", projectKind: "git", iconWorkingDir: "/repo", @@ -68,7 +68,7 @@ function projectionInput(options?: { [pinned.entry.workspaceKey, pinned.entry], [unpinned.entry.workspaceKey, unpinned.entry], ]), - projectNamesByKey: new Map([["project", "Project"]]), + projectNamesByViewKey: new Map([["project", "Project"]]), groupMode: options?.groupMode ?? ("project" as const), pinnedCollapsed: options?.pinnedCollapsed ?? false, collapsedProjectKeys: new Set(), diff --git a/packages/app/src/components/sidebar/sidebar-projection.ts b/packages/app/src/components/sidebar/sidebar-projection.ts index 641fb7e2a9..be96f55979 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.ts @@ -25,7 +25,7 @@ export function buildSidebarProjection(input: { projects: SidebarProjectEntry[]; pinnedKeys: PinnedSidebarKeys; workspaceEntriesByKey: ReadonlyMap; - projectNamesByKey: Map; + projectNamesByViewKey: Map; groupMode: SidebarGroupMode; pinnedCollapsed: boolean; collapsedProjectKeys: ReadonlySet; @@ -42,7 +42,7 @@ export function buildSidebarProjection(input: { Array.from(input.workspaceEntriesByKey.values()).filter( (workspace) => !pinnedWorkspaceKeys.has(workspace.workspaceKey), ), - input.projectNamesByKey, + input.projectNamesByViewKey, ) : []; @@ -61,7 +61,7 @@ export function buildSidebarProjection(input: { sections.push( ...pinnedGroups.unpinnedProjects.map((project) => ({ workspaces: project.workspaces, - collapsed: input.collapsedProjectKeys.has(project.projectKey), + collapsed: input.collapsedProjectKeys.has(project.viewKey), })), ); } diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 35444cc2ab..2a622679a7 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -64,8 +64,8 @@ const ThemedCircleX = withUnistyles(CircleX); interface StatusWorkspaceListProps { groups: StatusGroup[]; pinnedWorkspaces: SidebarWorkspaceEntry[]; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -79,8 +79,8 @@ interface StatusWorkspaceListProps { export function SidebarStatusWorkspaceList({ groups, pinnedWorkspaces, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, shortcutIndexByWorkspaceKey, showShortcutBadges, onWorkspacePress, @@ -118,8 +118,8 @@ export function SidebarStatusWorkspaceList({ workspace={workspace} {...buildStatusRowProjectPresentation({ workspace, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, hostLabelByServerId, showHostLabels, })} @@ -145,8 +145,8 @@ export function SidebarStatusWorkspaceList({ ; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -215,8 +215,8 @@ function StatusGroupList({ key={group.bucket} group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} - projectNamesByKey={projectNamesByKey} - projectIconByProjectKey={projectIconByProjectKey} + projectNamesByViewKey={projectNamesByViewKey} + projectIconByProjectViewKey={projectIconByProjectViewKey} shortcutIndex={shortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} @@ -233,8 +233,8 @@ function StatusGroupList({ function StatusGroupRows({ group, collapsed, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, shortcutIndex, showShortcutBadges, onWorkspacePress, @@ -245,8 +245,8 @@ function StatusGroupRows({ }: { group: StatusGroup; collapsed: boolean; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; @@ -276,8 +276,8 @@ function StatusGroupRows({ workspace={workspace} {...buildStatusRowProjectPresentation({ workspace, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, hostLabelByServerId, showHostLabels, })} @@ -324,25 +324,25 @@ interface StatusRowProjectPresentation { function buildStatusRowProjectPresentation({ workspace, - projectNamesByKey, - projectIconByProjectKey, + projectNamesByViewKey, + projectIconByProjectViewKey, hostLabelByServerId, showHostLabels, }: { workspace: SidebarWorkspaceEntry; - projectNamesByKey: Map; - projectIconByProjectKey: ReadonlyMap; + projectNamesByViewKey: Map; + projectIconByProjectViewKey: ReadonlyMap; hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; }): StatusRowProjectPresentation { - const projectName = projectNamesByKey.get(workspace.projectKey) ?? ""; + const projectName = projectNamesByViewKey.get(workspace.projectViewKey) ?? ""; const hostLabel = showHostLabels ? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId) : null; return { subtitle: buildStatusRowSubtitle({ projectName, hostLabel }), projectName, - projectIconDataUri: projectIconByProjectKey.get(workspace.projectKey) ?? null, + projectIconDataUri: projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null, }; } diff --git a/packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx b/packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx index 37ce838334..5e609821e8 100644 --- a/packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx +++ b/packages/app/src/components/sidebar/sidebar-subtitle-project-icon.tsx @@ -10,12 +10,12 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp * the meaning, so the icon itself stays out of the accessibility tree. */ export function SidebarSubtitleProjectIcon({ - projectKey, + projectViewKey, projectName, iconDataUri, testID, }: { - projectKey: string; + projectViewKey: string; projectName: string; iconDataUri: string | null; testID?: string; @@ -37,7 +37,7 @@ export function SidebarSubtitleProjectIcon({ {subtitleProjectName ? ( { ]); const workspaces = [ - ws({ workspaceKey: "srv:1", statusBucket: "done", projectKey: "proj-b", name: "zebra" }), - ws({ workspaceKey: "srv:2", statusBucket: "done", projectKey: "proj-a", name: "alpha" }), - ws({ workspaceKey: "srv:3", statusBucket: "done", projectKey: "proj-a", name: "alpha" }), + ws({ + workspaceKey: "srv:1", + statusBucket: "done", + projectViewKey: "proj-b", + name: "zebra", + }), + ws({ + workspaceKey: "srv:2", + statusBucket: "done", + projectViewKey: "proj-a", + name: "alpha", + }), + ws({ + workspaceKey: "srv:3", + statusBucket: "done", + projectViewKey: "proj-a", + name: "alpha", + }), ]; const groups = buildStatusGroups(workspaces, projectNames); diff --git a/packages/app/src/hooks/sidebar-status-view-model.ts b/packages/app/src/hooks/sidebar-status-view-model.ts index f22539edbf..7a2738091c 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.ts @@ -26,7 +26,7 @@ export interface StatusGroup { export function buildStatusGroups( workspaces: SidebarWorkspaceEntry[], - projectNamesByKey: Map, + projectNamesByViewKey: Map, ): StatusGroup[] { const bucketRows = new Map(); @@ -46,7 +46,7 @@ export function buildStatusGroups( const rows = bucketRows.get(bucket); if (!rows || rows.length === 0) continue; - rows.sort((a, b) => compareStatusRows(a, b, projectNamesByKey)); + rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey)); groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows }); } @@ -56,7 +56,7 @@ export function buildStatusGroups( function compareStatusRows( a: SidebarWorkspaceEntry, b: SidebarWorkspaceEntry, - projectNamesByKey: Map, + projectNamesByViewKey: Map, ): number { const aTime = a.statusEnteredAt?.getTime() ?? null; const bTime = b.statusEnteredAt?.getTime() ?? null; @@ -69,8 +69,8 @@ function compareStatusRows( return 1; } - const aProject = projectNamesByKey.get(a.projectKey) ?? ""; - const bProject = projectNamesByKey.get(b.projectKey) ?? ""; + const aProject = projectNamesByViewKey.get(a.projectViewKey) ?? ""; + const bProject = projectNamesByViewKey.get(b.projectViewKey) ?? ""; const projectCmp = aProject.localeCompare(bProject); if (projectCmp !== 0) return projectCmp; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 9816485741..5f4e666d02 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -83,6 +83,7 @@ function project(input: { >; }): WorkspaceStructureProject { return { + viewKey: input.projectKey, projectKey: input.projectKey, projectName: input.projectName ?? input.projectKey, projectKind: input.projectKind ?? "git", @@ -227,7 +228,7 @@ describe("buildSidebarProjectsFromStructure", () => { ], }); - expect(projects.map((entry) => entry.projectKey)).toEqual(["project-b", "project-a"]); + expect(projects.map((entry) => entry.viewKey)).toEqual(["project-b", "project-a"]); }); it("preserves the structure hook workspace order", () => { @@ -327,7 +328,7 @@ describe("shared sidebar workspace model", () => { ]); expect(model.projects).toEqual([ expect.objectContaining({ - projectKey: "getpaseo/paseo", + viewKey: "getpaseo/paseo", hosts: [ { serverId: "host-a", @@ -366,7 +367,7 @@ describe("shared sidebar workspace model", () => { ["host-a:main", "done", "main"], ["host-b:feature", "running", "feature/status-flow"], ]); - expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]])); + expect(model.projectNamesByViewKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]])); }); it("preserves unchanged row identities when another workspace updates", () => { @@ -443,7 +444,7 @@ describe("shared sidebar workspace model", () => { ], }); - expect(entries.get("srv:clone-a")?.projectKey).toBe(projectKey); + expect(entries.get("srv:clone-a")?.projectViewKey).toBe(projectKey); }); }); @@ -529,8 +530,8 @@ describe("computeSidebarOrderUpdates", () => { expect(updates.projectOrder).toEqual(["project-a", "project-b"]); expect(updates.workspaceOrders).toEqual([ - { projectKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] }, - { projectKey: "project-b", order: ["srv:ws-3"] }, + { projectViewKey: "project-a", order: ["srv:ws-2", "srv:ws-1"] }, + { projectViewKey: "project-b", order: ["srv:ws-3"] }, ]); }); @@ -550,7 +551,7 @@ describe("computeSidebarOrderUpdates", () => { expect(updates.workspaceOrders).toEqual([ { - projectKey: "project-a", + projectViewKey: "project-a", order: ["srv:newest", "srv:newer", "srv:old-b", "srv:old-a"], }, ]); diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 937111e79d..6feb14cb84 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -19,7 +19,7 @@ export interface SidebarWorkspacePlacement { workspaceKey: string; serverId: string; workspaceId: string; - projectKey: string; + projectViewKey: string; projectName: string; projectRootPath?: string; workspaceDirectory?: string; @@ -50,7 +50,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement { } export interface SidebarProjectEntry { - projectKey: string; + viewKey: string; projectName: string; projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; @@ -61,7 +61,7 @@ export interface SidebarProjectEntry { export interface SidebarWorkspacePlacementModel { workspaces: SidebarWorkspacePlacement[]; projects: SidebarProjectEntry[]; - projectNamesByKey: Map; + projectNamesByViewKey: Map; } export interface SidebarWorkspaceSession { @@ -122,11 +122,11 @@ interface EffectiveWorkspaceStatus { enteredAt: Date | null; } -function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string { +function projectNameForWorkspace(workspace: WorkspaceDescriptor): string { return ( workspace.projectCustomName ?? workspace.projectDisplayName ?? - projectDisplayNameFromProjectId(projectKey) + projectDisplayNameFromProjectId(workspace.projectId) ); } @@ -141,18 +141,18 @@ function normalizeCurrentBranch(currentBranch: string | null | undefined): strin export function createSidebarWorkspaceEntry(input: { serverId: string; workspace: WorkspaceDescriptor; - projectKey?: string; + projectViewKey?: string; pendingCreateAttempts?: Record; workspaceAgentActivity?: ReadonlyMap; }): SidebarWorkspaceEntry { - const projectKey = input.projectKey ?? input.workspace.projectId; + const projectViewKey = input.projectViewKey ?? input.workspace.projectId; const effectiveStatus = deriveEffectiveWorkspaceStatus(input); return { workspaceKey: `${input.serverId}:${input.workspace.id}`, serverId: input.serverId, workspaceId: input.workspace.id, - projectKey, - projectName: projectNameForWorkspace(input.workspace, projectKey), + projectViewKey, + projectName: projectNameForWorkspace(input.workspace), projectRootPath: input.workspace.projectRootPath, workspaceDirectory: input.workspace.workspaceDirectory, projectKind: input.workspace.projectKind, @@ -228,8 +228,8 @@ export function buildSidebarWorkspacePlacementModel(input: { return { projects, workspaces: projects.flatMap((project) => project.workspaces), - projectNamesByKey: new Map( - projects.map((project) => [project.projectKey, project.projectName]), + projectNamesByViewKey: new Map( + projects.map((project) => [project.viewKey, project.projectName]), ), }; } @@ -247,7 +247,7 @@ function createStructuralWorkspaceEntry(input: { workspaceKey: identity.workspaceKey, serverId: identity.serverId, workspaceId: identity.workspaceId, - projectKey: input.project.projectKey, + projectViewKey: input.project.viewKey, projectName: input.project.projectName, projectRootPath: input.project.iconWorkingDir, workspaceDirectory: undefined, @@ -324,7 +324,7 @@ export function buildSidebarWorkspaceEntries(input: { const entry = createSidebarWorkspaceEntry({ serverId: placement.serverId, workspace, - projectKey: placement.projectKey, + projectViewKey: placement.projectViewKey, pendingCreateAttempts: input.pendingCreateAttempts, workspaceAgentActivity: session.workspaceAgentActivity, }); @@ -369,6 +369,7 @@ export function buildSidebarProjectsFromStructure(input: { }): SidebarProjectEntry[] { return buildSidebarProjectsFromHostProjects({ projects: input.projects.map((project) => ({ + viewKey: project.viewKey, projectKey: project.projectKey, projectName: project.projectName, projectKind: project.projectKind, @@ -387,7 +388,7 @@ export function buildSidebarProjectsFromHostProjects(input: { } return input.projects.map((project) => ({ - projectKey: project.projectKey, + viewKey: project.viewKey, projectName: project.projectName, projectKind: project.projectKind, iconWorkingDir: project.iconWorkingDir, @@ -498,13 +499,13 @@ export function prependMissingOrderKeys(input: { export interface SidebarOrderUpdates { projectOrder: string[] | null; - workspaceOrders: Array<{ projectKey: string; order: string[] }>; + workspaceOrders: Array<{ projectViewKey: string; order: string[] }>; } export function computeSidebarOrderUpdates(input: { projects: SidebarProjectEntry[]; persistedProjectOrder: string[]; - getWorkspaceOrder: (projectKey: string) => string[]; + getWorkspaceOrder: (projectViewKey: string) => string[]; }): SidebarOrderUpdates { if (input.projects.length === 0) { return { projectOrder: null, workspaceOrders: [] }; @@ -512,19 +513,19 @@ export function computeSidebarOrderUpdates(input: { const nextProjectOrder = appendMissingOrderKeys({ currentOrder: input.persistedProjectOrder, - visibleKeys: input.projects.map((project) => project.projectKey), + visibleKeys: input.projects.map((project) => project.viewKey), }); const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder; - const workspaceOrders: Array<{ projectKey: string; order: string[] }> = []; + const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = []; for (const project of input.projects) { - const persistedWorkspaceOrder = input.getWorkspaceOrder(project.projectKey); + const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey); const nextWorkspaceOrder = prependMissingOrderKeys({ currentOrder: persistedWorkspaceOrder, visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), }); if (nextWorkspaceOrder !== persistedWorkspaceOrder) { - workspaceOrders.push({ projectKey: project.projectKey, order: nextWorkspaceOrder }); + workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder }); } } diff --git a/packages/app/src/hooks/use-sidebar-pins.test.ts b/packages/app/src/hooks/use-sidebar-pins.test.ts index 3c2962314e..80af801e07 100644 --- a/packages/app/src/hooks/use-sidebar-pins.test.ts +++ b/packages/app/src/hooks/use-sidebar-pins.test.ts @@ -10,7 +10,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement { workspaceKey, serverId: "s1", workspaceId: workspaceKey, - projectKey: "p1", + projectViewKey: "p1", projectName: "Project 1", projectKind: "git", workspaceKind: "worktree", @@ -20,7 +20,7 @@ function placement(workspaceKey: string): SidebarWorkspacePlacement { function project(projectKey: string, workspaces: SidebarWorkspacePlacement[]): SidebarProjectEntry { return { - projectKey, + viewKey: projectKey, projectName: projectKey, projectKind: "git", iconWorkingDir: "", diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx index d8a59e08b0..c54fa0cd2d 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx @@ -17,7 +17,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt workspaceKey: `srv:${workspaceId}`, serverId: "srv", workspaceId, - projectKey, + projectViewKey: projectKey, projectName: projectKey, projectRootPath: `/repo/${projectKey}`, workspaceDirectory: `/repo/${projectKey}/${workspaceId}`, @@ -40,7 +40,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt function project(projectKey: string): SidebarProjectEntry { return { - projectKey, + viewKey: projectKey, projectName: projectKey, projectKind: "git", iconWorkingDir: `/repo/${projectKey}`, diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index a47d6d2772..5491aadf3f 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -41,7 +41,7 @@ const EMPTY_PROJECT_NAMES = new Map(); export interface SidebarWorkspacesListResult { workspacePlacements: SidebarWorkspacePlacement[]; projects: SidebarProjectEntry[]; - projectNamesByKey: Map; + projectNamesByViewKey: Map; isLoading: boolean; isInitialLoad: boolean; isRevalidating: boolean; @@ -100,23 +100,25 @@ export function useSidebarWorkspacesList(options?: { const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS; const workspacePlacements = sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES; - const projectNamesByKey = - sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES; + const projectNamesByViewKey = + sidebarModel.projectNamesByViewKey.size > 0 + ? sidebarModel.projectNamesByViewKey + : EMPTY_PROJECT_NAMES; useEffect(() => { const orderStore = useSidebarOrderStore.getState(); const updates = computeSidebarOrderUpdates({ projects, persistedProjectOrder, - getWorkspaceOrder: (projectKey) => - orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER, + getWorkspaceOrder: (projectViewKey) => + orderStore.workspaceOrderByProject[projectViewKey] ?? EMPTY_ORDER, }); if (updates.projectOrder) { orderStore.setProjectOrder(updates.projectOrder); } - for (const { projectKey, order } of updates.workspaceOrders) { - orderStore.setWorkspaceOrder(projectKey, order); + for (const { projectViewKey, order } of updates.workspaceOrders) { + orderStore.setWorkspaceOrder(projectViewKey, order); } }, [persistedProjectOrder, projects]); @@ -144,7 +146,7 @@ export function useSidebarWorkspacesList(options?: { return { workspacePlacements, projects, - projectNamesByKey, + projectNamesByViewKey, ...loadingState, refreshAll, }; diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts index f87c0a19ea..c0a2d8c1aa 100644 --- a/packages/app/src/projects/host-project-model.ts +++ b/packages/app/src/projects/host-project-model.ts @@ -3,6 +3,7 @@ import type { WorkspaceStructureHostPlacement, WorkspaceStructureProject, } from "@/projects/workspace-structure"; +import { createProjectViewKey } from "@/projects/workspace-structure"; export type HostProjectListItem = WorkspaceStructureProject; @@ -31,7 +32,8 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec return null; } return { - projectKey: projectId, + viewKey: createProjectViewKey({ kind: "placement", serverId: route.serverId, projectId }), + projectKey: null, projectName: trimOptional(route.displayName) || projectId, projectKind: "git", iconWorkingDir, @@ -64,7 +66,12 @@ export function hostProjectFromWorkspace(input: { } const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind); return { - projectKey: projectId, + viewKey: createProjectViewKey({ + kind: "placement", + serverId: input.serverId, + projectId, + }), + projectKey: null, projectName: input.workspace.projectDisplayName || projectId, projectKind: input.workspace.projectKind, iconWorkingDir, @@ -131,6 +138,67 @@ export function filterWorkspaceProjectsForHost(input: { ); } +function compareHostProjectsForServer( + left: HostProjectListItem, + right: HostProjectListItem, + serverId: string, +): number { + return ( + left.projectName.localeCompare(right.projectName, undefined, { + numeric: true, + sensitivity: "base", + }) || + (getHostProjectId(left, serverId) ?? "").localeCompare( + getHostProjectId(right, serverId) ?? "", + ) || + left.viewKey.localeCompare(right.viewKey) + ); +} + +export function resolveExactHostProjectCandidate(input: { + candidate: HostProjectListItem; + projects: readonly HostProjectListItem[]; + serverId: string; +}): HostProjectListItem | null { + const candidatePlacement = getHostProjectId(input.candidate, input.serverId); + if (candidatePlacement) { + const exactPlacement = input.projects.find( + (project) => getHostProjectId(project, input.serverId) === candidatePlacement, + ); + if (exactPlacement) return exactPlacement; + } + + const exactView = input.projects.find((project) => project.viewKey === input.candidate.viewKey); + if (input.candidate.projectKey !== null && exactView?.projectKey === input.candidate.projectKey) { + return exactView; + } + + return null; +} + +export function resolveEquivalentHostProjectCandidate(input: { + candidate: HostProjectListItem; + projects: readonly HostProjectListItem[]; + serverId: string; +}): HostProjectListItem | null { + if (input.candidate.projectKey === null) return null; + const equivalents = input.projects.filter( + (project) => project.projectKey === input.candidate.projectKey, + ); + if (equivalents.length === 0) return null; + return equivalents.toSorted((left, right) => + compareHostProjectsForServer(left, right, input.serverId), + )[0]!; +} + +export function resolveHostProjectCandidate(input: { + candidate: HostProjectListItem; + projects: readonly HostProjectListItem[]; + serverId: string; +}): HostProjectListItem | null { + return resolveExactHostProjectCandidate(input) ?? resolveEquivalentHostProjectCandidate(input); +} + export function resolveInitialWorkspaceProject(input: { routeProject: HostProjectListItem | null; lastActiveProject: HostProjectListItem | null; @@ -143,20 +211,12 @@ export function resolveInitialWorkspaceProject(input: { if (!candidate) { continue; } - const candidatePlacement = candidate.hosts.find( - (host) => host.serverId === input.serverId && host.projectId, - ); const hydratedProject = - (candidatePlacement - ? input.projects.find((project) => - project.hosts.some( - (host) => - host.serverId === input.serverId && host.projectId === candidatePlacement.projectId, - ), - ) - : undefined) ?? - input.projects.find((project) => project.projectKey === candidate.projectKey) ?? - candidate; + resolveHostProjectCandidate({ + candidate, + projects: input.projects, + serverId: input.serverId, + }) ?? candidate; if ( canCreateWorkspaceForHostProject({ project: hydratedProject, @@ -186,19 +246,19 @@ export function resolveInitialWorktreeProject(input: { } export function resolveSelectedHostProject(input: { - selectedProjectKey: string | null; + selectedViewKey: string | null; projects: readonly HostProjectListItem[]; routeProject: HostProjectListItem | null; lastActiveProject: HostProjectListItem | null; }): HostProjectListItem | null { - const selectedProjectKey = input.selectedProjectKey?.trim() ?? ""; - if (!selectedProjectKey) { + const selectedViewKey = input.selectedViewKey?.trim() ?? ""; + if (!selectedViewKey) { return null; } return ( - input.projects.find((project) => project.projectKey === selectedProjectKey) ?? - (input.routeProject?.projectKey === selectedProjectKey ? input.routeProject : null) ?? - (input.lastActiveProject?.projectKey === selectedProjectKey ? input.lastActiveProject : null) + input.projects.find((project) => project.viewKey === selectedViewKey) ?? + (input.routeProject?.viewKey === selectedViewKey ? input.routeProject : null) ?? + (input.lastActiveProject?.viewKey === selectedViewKey ? input.lastActiveProject : null) ); } diff --git a/packages/app/src/projects/host-projects.test.ts b/packages/app/src/projects/host-projects.test.ts index c16deb34fc..6267ea267e 100644 --- a/packages/app/src/projects/host-projects.test.ts +++ b/packages/app/src/projects/host-projects.test.ts @@ -9,6 +9,7 @@ import { function project(): HostProjectListItem { return { + viewKey: "view:acme/app", projectKey: "remote:github.com/acme/app", projectName: "acme/app", projectKind: "git", @@ -57,7 +58,7 @@ describe("host project lookups", () => { sourceDirectory: "/repo/a", }), ).toMatchObject({ - projectKey: "prj_a", + projectKey: null, hosts: [{ serverId: "host-a", projectId: "prj_a" }], }); }); diff --git a/packages/app/src/projects/host-projects.ts b/packages/app/src/projects/host-projects.ts index fb12ebe7df..094a459b0a 100644 --- a/packages/app/src/projects/host-projects.ts +++ b/packages/app/src/projects/host-projects.ts @@ -9,6 +9,9 @@ export { getHostProjectId, hostProjectFromRoute, hostProjectFromWorkspace, + resolveHostProjectCandidate, + resolveExactHostProjectCandidate, + resolveEquivalentHostProjectCandidate, resolveInitialWorkspaceProject, resolveInitialWorktreeProject, resolveSelectedHostProject, diff --git a/packages/app/src/projects/project-icons.ts b/packages/app/src/projects/project-icons.ts index f0b4e4c020..af5ee498fb 100644 --- a/packages/app/src/projects/project-icons.ts +++ b/packages/app/src/projects/project-icons.ts @@ -5,7 +5,7 @@ import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-i export interface ProjectIconRequestTarget { serverId: string; - projectKey: string; + projectViewKey: string; iconWorkingDir: string; } @@ -20,7 +20,7 @@ function useStableProjectIconData( return stableRef.current.data; } -export function useProjectIconDataByProjectKey(input: { +export function useProjectIconDataByProjectViewKey(input: { projects: readonly ProjectIconRequestTarget[]; }): Map { const projectIconRequests = useMemo(() => { @@ -80,11 +80,11 @@ export function useProjectIconDataByProjectKey(input: { for (const project of input.projects) { const cwd = project.iconWorkingDir.trim(); if (!cwd) { - byProject.set(project.projectKey, null); + byProject.set(project.projectViewKey, null); continue; } byProject.set( - project.projectKey, + project.projectViewKey, iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null, ); } diff --git a/packages/app/src/projects/project-remove.test.ts b/packages/app/src/projects/project-remove.test.ts index 3a458a42a3..e94efc9a17 100644 --- a/packages/app/src/projects/project-remove.test.ts +++ b/packages/app/src/projects/project-remove.test.ts @@ -6,7 +6,6 @@ import { } from "./project-remove"; const project: ProjectRemoveProject = { - projectKey: "remote:github.com/acme/app", hosts: [ { serverId: "host-a", projectId: "prj_host_a" }, { serverId: "host-b", projectId: "prj_host_b" }, diff --git a/packages/app/src/projects/project-remove.ts b/packages/app/src/projects/project-remove.ts index 330ea09238..f80b068cf0 100644 --- a/packages/app/src/projects/project-remove.ts +++ b/packages/app/src/projects/project-remove.ts @@ -8,7 +8,6 @@ interface ProjectRemoveHost { } export interface ProjectRemoveProject { - projectKey: string; hosts: readonly ProjectRemoveHost[]; } diff --git a/packages/app/src/projects/workspace-structure.test.ts b/packages/app/src/projects/workspace-structure.test.ts index 7b621198e2..350f8855a7 100644 --- a/packages/app/src/projects/workspace-structure.test.ts +++ b/packages/app/src/projects/workspace-structure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; -import { buildWorkspaceStructureProjects } from "./workspace-structure"; +import { buildWorkspaceStructureProjects, createProjectViewKey } from "./workspace-structure"; function project(input: { id: string; @@ -57,6 +57,7 @@ describe("buildWorkspaceStructureProjects", () => { expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ + viewKey: key, projectKey: key, hosts: [ { serverId: "host-a", projectId: "prj_a" }, @@ -85,6 +86,7 @@ describe("buildWorkspaceStructureProjects", () => { }); expect(result).toHaveLength(2); + expect(result.map((item) => item.projectKey)).toEqual([key, key]); expect(result.map((item) => item.hosts[0]?.projectId).sort()).toEqual(["prj_one", "prj_two"]); expect(result.map((item) => item.workspaceKeys[0]).sort()).toEqual([ "host-a:ws-one", @@ -112,7 +114,7 @@ describe("buildWorkspaceStructureProjects", () => { const identitiesByProjectId = (projects: ReturnType) => { const identities: Record = {}; for (const group of projects) { - for (const host of group.hosts) identities[host.projectId] = group.projectKey; + for (const host of group.hosts) identities[host.projectId] = group.viewKey; } return identities; }; @@ -142,6 +144,33 @@ describe("buildWorkspaceStructureProjects", () => { }); expect(result).toHaveLength(2); - expect(new Set(result.map((item) => item.projectKey)).size).toBe(2); + expect(result.map((item) => item.projectKey)).toEqual([null, null]); + expect(new Set(result.map((item) => item.viewKey)).size).toBe(2); + }); + + test("keeps opaque project keys separate from placement view keys", () => { + const placementShapedKey = createProjectViewKey({ + kind: "placement", + serverId: "host-a", + projectId: "prj_b", + }); + const result = buildWorkspaceStructureProjects({ + sessions: [ + { + serverId: "host-a", + projects: [ + project({ id: "prj_a", key: placementShapedKey, root: "/repos/a" }), + project({ id: "prj_b", key: null, root: "/repos/b" }), + ], + workspaces: [], + }, + ], + }); + + expect(result).toHaveLength(2); + expect(new Set(result.map((item) => item.viewKey)).size).toBe(2); + expect(result.find((item) => item.projectKey === placementShapedKey)?.viewKey).toBe( + placementShapedKey, + ); }); }); diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 9fc635063b..db25238c31 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -9,7 +9,8 @@ export interface WorkspaceStructureHostPlacement { } export interface WorkspaceStructureProject { - projectKey: string; + viewKey: string; + projectKey: string | null; projectName: string; projectKind: WorkspaceDescriptor["projectKind"]; iconWorkingDir: string; @@ -28,7 +29,8 @@ interface WorkspaceStructureSession { } interface ProjectDraft { - projectKey: string; + viewKey: string; + projectKey: string | null; projectName: string; hasCustomName: boolean; projectKind: WorkspaceDescriptor["projectKind"]; @@ -57,19 +59,29 @@ export function buildWorkspaceStructureProjects(input: { } } + const allocatedViewKeys = new Set( + projectEntries.flatMap(({ project }) => (project.projectKey ? [project.projectKey] : [])), + ); + for (const { serverId, project } of projectEntries) { - const projectKey = addProjectToView({ byProject, keyCountsByServer, serverId, project }); + const viewKey = addProjectToView({ + byProject, + keyCountsByServer, + allocatedViewKeys, + serverId, + project, + }); getOrCreate(viewKeyByServerProjectId, serverId, () => new Map()).set( project.projectId, - projectKey, + viewKey, ); } for (const session of input.sessions) { for (const workspace of session.workspaces) { - const projectKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); - if (!projectKey) continue; - byProject.get(projectKey)?.workspaces.push({ + const viewKey = viewKeyByServerProjectId.get(session.serverId)?.get(workspace.projectId); + if (!viewKey) continue; + byProject.get(viewKey)?.workspaces.push({ workspaceId: workspace.id, workspaceName: workspace.name, workspaceKey: `${session.serverId}:${workspace.id}`, @@ -79,6 +91,7 @@ export function buildWorkspaceStructureProjects(input: { return Array.from(byProject.values()) .map((draft) => ({ + viewKey: draft.viewKey, projectKey: draft.projectKey, projectName: draft.projectName, projectKind: draft.projectKind, @@ -88,17 +101,48 @@ export function buildWorkspaceStructureProjects(input: { .sort(compareWorkspaceStructureItems) .map((workspace) => workspace.workspaceKey), })) - .sort((left, right) => - left.projectName.localeCompare(right.projectName, undefined, { - numeric: true, - sensitivity: "base", - }), + .sort( + (left, right) => + left.projectName.localeCompare(right.projectName, undefined, { + numeric: true, + sensitivity: "base", + }) || left.viewKey.localeCompare(right.viewKey), ); } +export function createProjectViewKey( + identity: + | { kind: "equivalence"; projectKey: string } + | { kind: "placement"; serverId: string; projectId: string }, +): string { + return identity.kind === "equivalence" + ? identity.projectKey + : JSON.stringify([identity.serverId, identity.projectId]); +} + +function allocatePlacementViewKey( + allocatedViewKeys: Set, + serverId: string, + projectId: string, +): string { + const legacyKey = createProjectViewKey({ kind: "placement", serverId, projectId }); + if (!allocatedViewKeys.has(legacyKey)) { + allocatedViewKeys.add(legacyKey); + return legacyKey; + } + + for (let suffix = 0; ; suffix += 1) { + const collisionKey = JSON.stringify(["placement", serverId, projectId, suffix]); + if (allocatedViewKeys.has(collisionKey)) continue; + allocatedViewKeys.add(collisionKey); + return collisionKey; + } +} + function addProjectToView(input: { byProject: Map; keyCountsByServer: Map>; + allocatedViewKeys: Set; serverId: string; project: ProjectDescriptor; }): string { @@ -106,17 +150,20 @@ function addProjectToView(input: { const sharedKey = project.projectKey ?? null; const canUseSharedKey = sharedKey !== null && keyCountsByServer.get(serverId)?.get(sharedKey) === 1; - const projectKey = canUseSharedKey ? sharedKey : JSON.stringify([serverId, project.projectId]); + const viewKey = canUseSharedKey + ? createProjectViewKey({ kind: "equivalence", projectKey: sharedKey }) + : allocatePlacementViewKey(input.allocatedViewKeys, serverId, project.projectId); const placement: WorkspaceStructureHostPlacement = { serverId, projectId: project.projectId, iconWorkingDir: project.projectRootPath, canCreateWorktree: project.projectKind === "git", }; - const draft = byProject.get(projectKey); + const draft = byProject.get(viewKey); if (!draft) { - byProject.set(projectKey, { - projectKey, + byProject.set(viewKey, { + viewKey, + projectKey: sharedKey, projectName: project.projectCustomName ?? project.projectDisplayName ?? @@ -134,7 +181,7 @@ function addProjectToView(input: { } draft.hosts.set(serverId, placement); } - return projectKey; + return viewKey; } function getOrCreate(map: Map, key: K, create: () => V): V { diff --git a/packages/app/src/schedules/schedule-form-model.test.ts b/packages/app/src/schedules/schedule-form-model.test.ts index d8daeffeed..51307fc0cf 100644 --- a/packages/app/src/schedules/schedule-form-model.test.ts +++ b/packages/app/src/schedules/schedule-form-model.test.ts @@ -54,7 +54,7 @@ function target(input: { optionId: buildProjectOptionId(input.serverId, input.projectKey), serverId: input.serverId, serverName: input.serverId === "host-a" ? "Host A" : "Host B", - projectKey: input.projectKey, + projectViewKey: input.projectKey, projectName: input.projectName, cwd: input.cwd, isGit: input.isGit ?? true, diff --git a/packages/app/src/schedules/schedule-project-targets.test.ts b/packages/app/src/schedules/schedule-project-targets.test.ts index 3dd3be0b2c..2cb6c65257 100644 --- a/packages/app/src/schedules/schedule-project-targets.test.ts +++ b/packages/app/src/schedules/schedule-project-targets.test.ts @@ -8,7 +8,7 @@ import { function makeProject(overrides: Partial): ProjectSummary { return { - projectKey: "proj", + viewKey: "proj", projectName: "Project", hosts: [], totalWorkspaceCount: 0, diff --git a/packages/app/src/schedules/schedule-project-targets.ts b/packages/app/src/schedules/schedule-project-targets.ts index 4ed5e3c5d4..a810e3091a 100644 --- a/packages/app/src/schedules/schedule-project-targets.ts +++ b/packages/app/src/schedules/schedule-project-targets.ts @@ -7,14 +7,14 @@ export interface ScheduleProjectTarget { optionId: string; serverId: string; serverName: string; - projectKey: string; + projectViewKey: string; projectName: string; cwd: string; isGit: boolean; } -export function buildProjectOptionId(serverId: string, projectKey: string): string { - return `${PROJECT_OPTION_PREFIX}${serverId}:${projectKey}`; +export function buildProjectOptionId(serverId: string, projectViewKey: string): string { + return `${PROJECT_OPTION_PREFIX}${serverId}:${projectViewKey}`; } /** @@ -33,10 +33,10 @@ export function buildScheduleProjectTargets( continue; } targets.push({ - optionId: buildProjectOptionId(host.serverId, project.projectKey), + optionId: buildProjectOptionId(host.serverId, project.viewKey), serverId: host.serverId, serverName: host.serverName, - projectKey: project.projectKey, + projectViewKey: project.viewKey, projectName: host.projectName, cwd, isGit: Boolean(host.gitRuntime), diff --git a/packages/app/src/schedules/use-schedule-form-model.test.tsx b/packages/app/src/schedules/use-schedule-form-model.test.tsx index a9cd3ac4eb..50c01645de 100644 --- a/packages/app/src/schedules/use-schedule-form-model.test.tsx +++ b/packages/app/src/schedules/use-schedule-form-model.test.tsx @@ -17,7 +17,7 @@ function projectTarget(input: { optionId: buildProjectOptionId("host-a", input.projectKey), serverId: "host-a", serverName: "Host A", - projectKey: input.projectKey, + projectViewKey: input.projectKey, projectName: input.projectName, cwd: input.cwd, isGit: true, diff --git a/packages/app/src/screens/new-workspace-initial-context.test.ts b/packages/app/src/screens/new-workspace-initial-context.test.ts index c48f35779c..969aae6d90 100644 --- a/packages/app/src/screens/new-workspace-initial-context.test.ts +++ b/packages/app/src/screens/new-workspace-initial-context.test.ts @@ -8,6 +8,7 @@ import { function projectFor(serverId: string, key = "project"): HostProjectListItem { return { + viewKey: `view:${serverId}:${key}`, projectKey: key, projectName: key, projectKind: "git", diff --git a/packages/app/src/screens/new-workspace-initial-context.ts b/packages/app/src/screens/new-workspace-initial-context.ts index 4ed87931e3..9edb2f2ca9 100644 --- a/packages/app/src/screens/new-workspace-initial-context.ts +++ b/packages/app/src/screens/new-workspace-initial-context.ts @@ -1,5 +1,7 @@ import { canCreateWorkspaceForHostProject, + filterWorkspaceProjectsForHost, + resolveHostProjectCandidate, type HostProjectListItem, } from "@/projects/host-projects"; import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; @@ -25,35 +27,31 @@ function supportsAllProjects( return workspaceMultiplicityByServerId.get(serverId) === true; } -function getProjectForServer(input: { - candidate: HostProjectListItem; - projects: readonly HostProjectListItem[]; - serverId: string; -}) { - return ( - input.projects.find( - (project) => - project.projectKey === input.candidate.projectKey && - project.hosts.some((host) => host.serverId === input.serverId), - ) ?? input.candidate - ); -} - function canUseProjectForServer(input: { project: HostProjectListItem; projects: readonly HostProjectListItem[]; serverId: string; workspaceMultiplicityByServerId: ReadonlyMap; }) { - const project = getProjectForServer({ - candidate: input.project, + const allowAllProjects = supportsAllProjects( + input.workspaceMultiplicityByServerId, + input.serverId, + ); + const selectableProjects = filterWorkspaceProjectsForHost({ projects: input.projects, serverId: input.serverId, + allowAllProjects, }); + const project = + resolveHostProjectCandidate({ + candidate: input.project, + projects: selectableProjects, + serverId: input.serverId, + }) ?? input.project; return canCreateWorkspaceForHostProject({ project, serverId: input.serverId, - allowAllProjects: supportsAllProjects(input.workspaceMultiplicityByServerId, input.serverId), + allowAllProjects, }); } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 5598633af5..6948f8370d 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -72,7 +72,7 @@ import { useHostProjects, type HostProjectListItem, } from "@/projects/host-projects"; -import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; @@ -273,7 +273,7 @@ function ProjectPickerTrigger({ disabled, badgePressableStyle, label, - projectKey, + projectViewKey, iconDataUri, iconColor, iconSize, @@ -283,7 +283,7 @@ function ProjectPickerTrigger({ disabled: boolean; badgePressableStyle: React.ComponentProps["style"]; label: string; - projectKey: string | null; + projectViewKey: string | null; iconDataUri: string | null; iconColor: string; iconSize: number; @@ -303,11 +303,11 @@ function ProjectPickerTrigger({ accessibilityLabel="Workspace project" > - {projectKey ? ( + {projectViewKey ? ( ), - [iconDataUri, placeholderInitial, projectKey], + [iconDataUri, placeholderInitial, projectViewKey], ); return ( @@ -533,7 +533,7 @@ function NewWorkspaceProjectPickerOption({ active, onPress, projectByOptionId, - projectIconDataByProjectKey, + projectIconDataByProjectViewKey, selectedServerId, isPending, supportsWorkspaceMultiplicity, @@ -543,7 +543,7 @@ function NewWorkspaceProjectPickerOption({ active: boolean; onPress: () => void; projectByOptionId: Map; - projectIconDataByProjectKey: Map; + projectIconDataByProjectViewKey: Map; selectedServerId: string; isPending: boolean; supportsWorkspaceMultiplicity: boolean; @@ -555,9 +555,9 @@ function NewWorkspaceProjectPickerOption({ return ( ; + iconDataByProjectViewKey: Map; selectedOptionId: string; onSelect: (id: string) => void; onAddProject: () => void; @@ -1369,10 +1369,10 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme disabled={isPending} badgePressableStyle={badgePressableStyle} label={project.triggerLabel} - projectKey={project.selectedProject?.projectKey ?? null} + projectViewKey={project.selectedProject?.viewKey ?? null} iconDataUri={ project.selectedProject - ? (project.iconDataByProjectKey.get(project.selectedProject.projectKey) ?? null) + ? (project.iconDataByProjectViewKey.get(project.selectedProject.viewKey) ?? null) : null } iconColor={theme.colors.foregroundMuted} @@ -1592,12 +1592,12 @@ export function NewWorkspaceScreen({ if (!iconWorkingDir) { return []; } - return [{ projectKey: project.projectKey, serverId: selectedServerId, iconWorkingDir }]; + return [{ projectViewKey: project.viewKey, serverId: selectedServerId, iconWorkingDir }]; }), [projects, selectedServerId], ); - const projectIconDataByProjectKey = useProjectIconDataByProjectKey({ + const projectIconDataByProjectViewKey = useProjectIconDataByProjectViewKey({ projects: projectIconTargets, }); const draftKey = buildNewWorkspaceDraftKey(draftId); @@ -2039,7 +2039,7 @@ export function NewWorkspaceScreen({ (); const options = projects.map((project) => { - const id = projectOptionId(project.projectKey); + const id = projectOptionId(project.viewKey); projectByOptionId.set(id, project); return { id, label: project.projectName }; }); @@ -104,15 +104,14 @@ export function useNewWorkspaceProjectPicker({ [allowAllProjects, lastActiveProject, routeProject, selectableProjects, selectedServerId], ); - const routeProjectKey = routeProject?.projectKey ?? null; + const routeProjectViewKey = routeProject?.viewKey ?? null; const selectionContextKey = createProjectSelectionContextKey({ selectedServerId, - routeProjectKey, + routeProjectViewKey, allowAllProjects, }); const manualSelectionContextKey = createManualProjectSelectionContextKey({ - selectedServerId, - routeProjectKey, + routeProjectViewKey, }); const shouldPreserveMissingProject = useCallback( (project: HostProjectListItem) => @@ -126,6 +125,7 @@ export function useNewWorkspaceProjectPicker({ () => ({ contextKey: selectionContextKey, manualContextKey: manualSelectionContextKey, + selectedServerId, initialProject, initialProjectSource: resolveInitialProjectSelectionSource({ initialProject, @@ -143,6 +143,7 @@ export function useNewWorkspaceProjectPicker({ manualSelectionContextKey, routeProject, selectableProjects, + selectedServerId, selectionContextKey, shouldPreserveMissingProject, ], @@ -168,8 +169,8 @@ export function useNewWorkspaceProjectPicker({ if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return; setProjectSelection({ contextKey: manualSelectionContextKey, - projectKey: project.projectKey, project, + originProject: project, source: "manual", }); }, @@ -183,7 +184,7 @@ export function useNewWorkspaceProjectPicker({ : null, projectPickerOptions, projectByOptionId, - selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.projectKey) : "", + selectedProjectOptionId: selectedProject ? projectOptionId(selectedProject.viewKey) : "", projectTriggerLabel: selectedProject?.projectName ?? "Choose project", handleSelectProjectOption, }; diff --git a/packages/app/src/screens/new-workspace/project-selection.test.ts b/packages/app/src/screens/new-workspace/project-selection.test.ts index 4387412316..0ec8caea8a 100644 --- a/packages/app/src/screens/new-workspace/project-selection.test.ts +++ b/packages/app/src/screens/new-workspace/project-selection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { HostProjectListItem } from "@/projects/host-projects"; +import { filterWorkspaceProjectsForHost, type HostProjectListItem } from "@/projects/host-projects"; import { createManualProjectSelectionContextKey, createProjectSelectionContextKey, @@ -13,6 +13,7 @@ import { function project(projectKey: string, serverId = "host"): HostProjectListItem { return { + viewKey: `view:${serverId}:${projectKey}`, projectKey, projectName: projectKey, projectKind: "git", @@ -40,7 +41,8 @@ function context( const lastActiveProject = input.lastActiveProject ?? null; return { contextKey, - manualContextKey: input.manualContextKey ?? contextKey, + manualContextKey: input.manualContextKey ?? "", + selectedServerId: input.selectedServerId ?? "host", routeProject, lastActiveProject, initialProjectSource: @@ -71,7 +73,6 @@ describe("reconcileProjectSelection", () => { expect(reconciled).toEqual({ contextKey: "host:", - projectKey: remembered.projectKey, project: remembered, source: "initial", }); @@ -80,15 +81,16 @@ describe("reconcileProjectSelection", () => { it("resets stale selection when the route project context changes", () => { const manual = project("manual"); - const routeProject = project("route-project"); + const routeProject = { ...project("route-project"), projectKey: null }; const current: ProjectSelection = { - contextKey: "host:previous-route", - projectKey: manual.projectKey, + contextKey: "previous-route", project: manual, + originProject: manual, source: "manual", }; const nextContext = context({ contextKey: "host:route-project", + manualContextKey: routeProject.viewKey, initialProject: routeProject, projects: [manual, routeProject], routeProject, @@ -96,7 +98,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, nextContext)).toEqual({ contextKey: "host:route-project", - projectKey: routeProject.projectKey, project: routeProject, source: "initial", }); @@ -112,7 +113,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, hydratedContext)).toEqual({ contextKey: "host:", - projectKey: initialProject.projectKey, project: initialProject, source: "initial", }); @@ -121,6 +121,7 @@ describe("reconcileProjectSelection", () => { it("adopts a routed project's hydrated cross-host key", () => { const routedProject = { ...project("local-project-id"), + projectKey: null, hosts: [ { serverId: "host", @@ -152,7 +153,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, hydratedContext)).toEqual({ contextKey: "host:", - projectKey: hydratedProject.projectKey, project: hydratedProject, source: "initial", }); @@ -177,7 +177,6 @@ describe("reconcileProjectSelection", () => { expect(hydratedSelection).toEqual({ contextKey: "host:", - projectKey: hydratedProject.projectKey, project: hydratedProject, source: "initial", }); @@ -207,7 +206,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual({ contextKey: "host:", - projectKey: remembered.projectKey, project: remembered, source: "initial", }); @@ -217,9 +215,9 @@ describe("reconcileProjectSelection", () => { const manual = project("manual"); const remembered = project("remembered"); const current: ProjectSelection = { - contextKey: "host:", - projectKey: manual.projectKey, + contextKey: "", project: manual, + originProject: manual, source: "manual", }; const afterRememberedHydration = context({ @@ -231,6 +229,295 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, afterRememberedHydration)).toEqual(current); }); + it("resolves a manual selection on another host while retaining its origin snapshot", () => { + const selected = project("shared", "host-a"); + const equivalent = project("shared", "host-b"); + const fallback = project("fallback", "host-b"); + const current: ProjectSelection = { + contextKey: "", + project: selected, + originProject: selected, + source: "manual", + }; + const nextHost = context({ + selectedServerId: "host-b", + initialProject: fallback, + projects: [fallback, equivalent], + }); + + const reconciled = reconcileProjectSelection(current, nextHost); + + expect(reconciled).toEqual({ + contextKey: "", + project: equivalent, + originProject: selected, + source: "manual", + }); + expect(resolveProjectSelection(reconciled, nextHost)).toBe(equivalent); + }); + + it("ignores a stale placement view collision and selects the persisted equivalent", () => { + const stalePlacementViewKey = JSON.stringify(["host-b", "opaque-project"]); + const selected = { + ...project("shared", "host-a"), + viewKey: stalePlacementViewKey, + }; + const unrelated = { + ...project(stalePlacementViewKey, "host-b"), + viewKey: stalePlacementViewKey, + }; + const equivalent = project("shared", "host-b"); + const current: ProjectSelection = { + contextKey: "", + project: selected, + originProject: selected, + source: "manual", + }; + const nextHost = context({ + selectedServerId: "host-b", + initialProject: unrelated, + projects: [unrelated, equivalent], + }); + + const reconciled = reconcileProjectSelection(current, nextHost); + + expect(reconciled).toEqual({ + contextKey: "", + project: equivalent, + originProject: selected, + source: "manual", + }); + expect(resolveProjectSelection(reconciled, nextHost)).toBe(equivalent); + }); + + it("chooses the same equivalent clone regardless of input order", () => { + const selected = project("shared", "host-a"); + const cloneA = { + ...project("shared", "host-b"), + viewKey: "view:host-b:clone-a", + projectName: "Shared", + hosts: [ + { + serverId: "host-b", + projectId: "prj_a", + iconWorkingDir: "/work/a", + canCreateWorktree: true, + }, + ], + }; + const cloneB = { + ...cloneA, + viewKey: "view:host-b:clone-b", + hosts: [{ ...cloneA.hosts[0]!, projectId: "prj_b", iconWorkingDir: "/work/b" }], + }; + const current: ProjectSelection = { + contextKey: "", + project: selected, + originProject: selected, + source: "manual", + }; + const resolve = (projects: HostProjectListItem[]) => { + const nextHost = context({ + selectedServerId: "host-b", + initialProject: cloneB, + projects, + }); + return resolveProjectSelection(reconcileProjectSelection(current, nextHost), nextHost); + }; + + expect(resolve([cloneB, cloneA])).toBe(cloneA); + expect(resolve([cloneA, cloneB])).toBe(cloneA); + }); + + it("restores the exact manually selected clone after switching away and back", () => { + const selected = { + ...project("shared", "host-a"), + viewKey: "view:host-a:selected", + hosts: [ + { + serverId: "host-a", + projectId: "prj_b", + iconWorkingDir: "/work/selected", + canCreateWorktree: true, + }, + ], + }; + const sibling = { + ...selected, + viewKey: "view:host-a:sibling", + hosts: [{ ...selected.hosts[0]!, projectId: "prj_a", iconWorkingDir: "/work/sibling" }], + }; + const destination = project("shared", "host-b"); + const current: ProjectSelection = { + contextKey: "", + project: selected, + originProject: selected, + source: "manual", + }; + const otherHost = context({ + selectedServerId: "host-b", + initialProject: destination, + projects: [destination], + }); + + const retained = reconcileProjectSelection(current, otherHost); + expect(retained).toEqual({ + contextKey: "", + project: destination, + originProject: selected, + source: "manual", + }); + expect(resolveProjectSelection(retained, otherHost)).toBe(destination); + + const originHost = context({ + selectedServerId: "host-a", + initialProject: sibling, + projects: [sibling, selected], + }); + const restored = reconcileProjectSelection(retained, originHost); + + expect(restored.project).toBe(selected); + expect(restored.source === "manual" ? restored.originProject : null).toBe(selected); + expect(resolveProjectSelection(restored, originHost)).toBe(selected); + }); + + it("preserves the current destination clone during its pending archive gap", () => { + const selected = project("shared", "host-a"); + const destination = { + ...project("shared", "host-b"), + workspaceKeys: ["host-b:workspace"], + }; + const fallback = project("fallback", "host-b"); + const current: ProjectSelection = { + contextKey: "", + project: selected, + originProject: selected, + source: "manual", + }; + const destinationHost = context({ + selectedServerId: "host-b", + initialProject: fallback, + projects: [fallback, destination], + }); + const resolvedDestination = reconcileProjectSelection(current, destinationHost); + const archiveGap = context({ + selectedServerId: "host-b", + initialProject: fallback, + projects: [fallback], + shouldPreserveMissingProject: (candidate) => candidate === destination, + }); + + expect(reconcileProjectSelection(resolvedDestination, archiveGap)).toBe(resolvedDestination); + expect(resolveProjectSelection(resolvedDestination, archiveGap)).toBe(destination); + }); + + it("preserves the exact origin clone during its pending archive gap", () => { + const selected = { + ...project("shared", "host-a"), + viewKey: "view:host-a:selected", + workspaceKeys: ["host-a:workspace"], + hosts: [ + { + serverId: "host-a", + projectId: "prj_b", + iconWorkingDir: "/work/selected", + canCreateWorktree: true, + }, + ], + }; + const sibling = { + ...selected, + viewKey: "view:host-a:sibling", + workspaceKeys: [], + hosts: [{ ...selected.hosts[0]!, projectId: "prj_a", iconWorkingDir: "/work/sibling" }], + }; + const destination = project("shared", "host-b"); + const current: ProjectSelection = { + contextKey: "", + project: selected, + originProject: selected, + source: "manual", + }; + const destinationHost = context({ + selectedServerId: "host-b", + initialProject: destination, + projects: [destination], + }); + const resolvedDestination = reconcileProjectSelection(current, destinationHost); + const archiveGap = context({ + selectedServerId: "host-a", + initialProject: sibling, + projects: [sibling], + shouldPreserveMissingProject: (candidate) => candidate === selected, + }); + + const preservedOrigin = reconcileProjectSelection(resolvedDestination, archiveGap); + + expect(preservedOrigin.project).toBe(selected); + expect(resolveProjectSelection(preservedOrigin, archiveGap)).toBe(selected); + }); + + it("falls back when the only equivalent project is not selectable", () => { + const selected = project("shared", "host-a"); + const unselectableEquivalent = { + ...project("shared", "host-b"), + hosts: [ + { + serverId: "host-b", + projectId: "shared", + iconWorkingDir: "/work/shared", + canCreateWorktree: false, + }, + ], + }; + const fallback = project("fallback", "host-b"); + const current: ProjectSelection = { + contextKey: "", + project: selected, + originProject: selected, + source: "manual", + }; + const selectableProjects = filterWorkspaceProjectsForHost({ + projects: [unselectableEquivalent, fallback], + serverId: "host-b", + allowAllProjects: false, + }); + const selectableContext = context({ + selectedServerId: "host-b", + initialProject: fallback, + projects: selectableProjects, + }); + + expect(reconcileProjectSelection(current, selectableContext).project).toBe(fallback); + }); + + it("does not equate projects without a persisted project key", () => { + const selected = { ...project("selected", "host-a"), projectKey: null }; + const sameName = { + ...project("same-name", "host-b"), + projectKey: null, + projectName: selected.projectName, + }; + const fallback = project("fallback", "host-b"); + const current: ProjectSelection = { + contextKey: "", + project: selected, + originProject: selected, + source: "manual", + }; + + expect( + reconcileProjectSelection( + current, + context({ + selectedServerId: "host-b", + initialProject: fallback, + projects: [sameName, fallback], + }), + ).project, + ).toBe(fallback); + }); + it("preserves an opaque project key ending in whitespace", () => { const selected = project("host:project "); const current = createProjectSelection( @@ -249,7 +536,7 @@ describe("reconcileProjectSelection", () => { context({ contextKey: createProjectSelectionContextKey({ selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, allowAllProjects: false, }), initialProject: fallback, @@ -259,7 +546,7 @@ describe("reconcileProjectSelection", () => { const afterCapabilityHydration = context({ contextKey: createProjectSelectionContextKey({ selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, allowAllProjects: true, }), initialProject: remembered, @@ -268,7 +555,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, afterCapabilityHydration)).toEqual({ contextKey: "host:all-projects:", - projectKey: remembered.projectKey, project: remembered, source: "initial", }); @@ -280,22 +566,20 @@ describe("reconcileProjectSelection", () => { const remembered = project("remembered-directory"); const current: ProjectSelection = { contextKey: createManualProjectSelectionContextKey({ - selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, }), - projectKey: manual.projectKey, project: manual, + originProject: manual, source: "manual", }; const afterCapabilityHydration = context({ contextKey: createProjectSelectionContextKey({ selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, allowAllProjects: true, }), manualContextKey: createManualProjectSelectionContextKey({ - selectedServerId: "host", - routeProjectKey: null, + routeProjectViewKey: null, }), initialProject: remembered, projects: [fallback, manual, remembered], @@ -325,6 +609,34 @@ describe("reconcileProjectSelection", () => { expect(resolveProjectSelection(reconciled, withoutRemembered)).toEqual(remembered); }); + it("keeps a pending archived clone instead of jumping to a same-host equivalent", () => { + const remembered = project("shared"); + const sibling = { + ...project("shared"), + viewKey: "view:host:sibling", + hosts: [ + { + serverId: "host", + projectId: "sibling", + iconWorkingDir: "/work/sibling", + canCreateWorktree: true, + }, + ], + }; + const fallback = project("fallback"); + const current = createProjectSelection( + context({ initialProject: remembered, projects: [remembered, sibling] }), + ); + const archiveGap = context({ + initialProject: fallback, + projects: [sibling, fallback], + shouldPreserveMissingProject: (candidate) => candidate.viewKey === remembered.viewKey, + }); + + expect(reconcileProjectSelection(current, archiveGap)).toEqual(current); + expect(resolveProjectSelection(current, archiveGap)).toEqual(remembered); + }); + it("falls back when the selected project disappears without a pending archive", () => { const remembered = project("remembered"); const fallback = project("fallback"); @@ -338,7 +650,6 @@ describe("reconcileProjectSelection", () => { expect(reconcileProjectSelection(current, withoutRemembered)).toEqual({ contextKey: "host:", - projectKey: fallback.projectKey, project: fallback, source: "initial", }); @@ -349,13 +660,14 @@ describe("reconcileProjectSelection", () => { const routeProject = project("route-project"); const remembered = project("remembered"); const current: ProjectSelection = { - contextKey: "host:route-project", - projectKey: manual.projectKey, + contextKey: routeProject.viewKey, project: manual, + originProject: manual, source: "manual", }; const selectionContext = context({ contextKey: "host:route-project", + manualContextKey: routeProject.viewKey, initialProject: routeProject, projects: [manual], routeProject, diff --git a/packages/app/src/screens/new-workspace/project-selection.ts b/packages/app/src/screens/new-workspace/project-selection.ts index e6eacd6e65..d3af9c9701 100644 --- a/packages/app/src/screens/new-workspace/project-selection.ts +++ b/packages/app/src/screens/new-workspace/project-selection.ts @@ -1,18 +1,32 @@ -import type { HostProjectListItem } from "@/projects/host-projects"; +import { + getHostProjectId, + resolveEquivalentHostProjectCandidate, + resolveExactHostProjectCandidate, + type HostProjectListItem, +} from "@/projects/host-projects"; export type ProjectSelectionSource = "initial" | "manual"; export type InitialProjectSelectionSource = "route" | "lastActive" | "fallback" | null; -export interface ProjectSelection { +interface InitialProjectSelection { contextKey: string; - projectKey: string | null; project: HostProjectListItem | null; - source: ProjectSelectionSource; + source: "initial"; } +interface ManualProjectSelection { + contextKey: string; + project: HostProjectListItem; + originProject: HostProjectListItem; + source: "manual"; +} + +export type ProjectSelection = InitialProjectSelection | ManualProjectSelection; + export interface ProjectSelectionContext { contextKey: string; manualContextKey: string; + selectedServerId: string; initialProject: HostProjectListItem | null; initialProjectSource: InitialProjectSelectionSource; projects: HostProjectListItem[]; @@ -23,18 +37,17 @@ export interface ProjectSelectionContext { export function createProjectSelectionContextKey(input: { selectedServerId: string; - routeProjectKey: string | null; + routeProjectViewKey: string | null; allowAllProjects: boolean; }): string { const projectScope = input.allowAllProjects ? "all-projects" : "worktree-projects"; - return `${input.selectedServerId}:${projectScope}:${input.routeProjectKey ?? ""}`; + return `${input.selectedServerId}:${projectScope}:${input.routeProjectViewKey ?? ""}`; } export function createManualProjectSelectionContextKey(input: { - selectedServerId: string; - routeProjectKey: string | null; + routeProjectViewKey: string | null; }): string { - return `${input.selectedServerId}:${input.routeProjectKey ?? ""}`; + return input.routeProjectViewKey ?? ""; } export function createProjectSelection({ @@ -43,7 +56,6 @@ export function createProjectSelection({ }: ProjectSelectionContext): ProjectSelection { return { contextKey, - projectKey: initialProject?.projectKey ?? null, project: initialProject, source: "initial", }; @@ -58,7 +70,7 @@ export function resolveInitialProjectSelectionSource(input: { return null; } if ( - input.routeProject?.projectKey === input.initialProject.projectKey || + input.routeProject?.viewKey === input.initialProject.viewKey || input.routeProject?.hosts.some((routeHost) => input.initialProject?.hosts.some( (host) => @@ -70,24 +82,24 @@ export function resolveInitialProjectSelectionSource(input: { ) { return "route"; } - if (input.lastActiveProject?.projectKey === input.initialProject.projectKey) { + if ( + input.lastActiveProject?.viewKey === input.initialProject.viewKey || + (input.lastActiveProject?.projectKey !== null && + input.lastActiveProject?.projectKey !== undefined && + input.lastActiveProject.projectKey === input.initialProject.projectKey) + ) { return "lastActive"; } return "fallback"; } -function resolveProjectSelectionKey(selection: ProjectSelection): string | null { - const projectKey = selection.projectKey ?? ""; - return projectKey.trim() ? projectKey : null; -} - function resolveSelectedProjectFromInitialInputs( - projectKey: string, + project: HostProjectListItem, context: ProjectSelectionContext, ): HostProjectListItem | null { return ( - (context.routeProject?.projectKey === projectKey ? context.routeProject : null) ?? - (context.lastActiveProject?.projectKey === projectKey ? context.lastActiveProject : null) + (context.routeProject?.viewKey === project.viewKey ? context.routeProject : null) ?? + (context.lastActiveProject?.viewKey === project.viewKey ? context.lastActiveProject : null) ); } @@ -95,16 +107,40 @@ function refreshSelectionProject( selection: ProjectSelection, project: HostProjectListItem, ): ProjectSelection { - if (selection.projectKey === project.projectKey && selection.project === project) { + if (selection.project === project) { return selection; } return { ...selection, - projectKey: project.projectKey, project, }; } +function resolveManualOriginProject( + selection: ProjectSelection, + context: ProjectSelectionContext, +): HostProjectListItem | null { + if (selection.source !== "manual") { + return null; + } + + const exactOriginProject = resolveExactHostProjectCandidate({ + candidate: selection.originProject, + projects: context.projects, + serverId: context.selectedServerId, + }); + if (exactOriginProject) { + return exactOriginProject; + } + + const originBelongsToSelectedHost = + getHostProjectId(selection.originProject, context.selectedServerId) !== null; + return originBelongsToSelectedHost && + context.shouldPreserveMissingProject(selection.originProject) + ? selection.originProject + : null; +} + function shouldResetHydratedInitialSelection( selection: ProjectSelection, context: ProjectSelectionContext, @@ -117,32 +153,48 @@ function shouldResetHydratedInitialSelection( return false; } - return selection.projectKey !== context.initialProject.projectKey; + return selection.project?.viewKey !== context.initialProject.viewKey; } export function resolveProjectSelection( selection: ProjectSelection, context: ProjectSelectionContext, ): HostProjectListItem | null { - const projectKey = resolveProjectSelectionKey(selection); - if (!projectKey) { + if (!selection.project) { return null; } - const selectableProject = context.projects.find((project) => project.projectKey === projectKey); - if (selectableProject) { - return selectableProject; + if (selection.source === "manual") { + const originProject = resolveManualOriginProject(selection, context); + if (originProject) { + return originProject; + } } - if ( - selection.project?.projectKey === projectKey && - context.shouldPreserveMissingProject(selection.project) - ) { + const exactProject = resolveExactHostProjectCandidate({ + candidate: selection.project, + projects: context.projects, + serverId: context.selectedServerId, + }); + if (exactProject) { + return exactProject; + } + + if (context.shouldPreserveMissingProject(selection.project)) { return selection.project; } + const equivalentProject = resolveEquivalentHostProjectCandidate({ + candidate: selection.project, + projects: context.projects, + serverId: context.selectedServerId, + }); + if (equivalentProject) { + return equivalentProject; + } + if (selection.source !== "manual") { - return resolveSelectedProjectFromInitialInputs(projectKey, context); + return resolveSelectedProjectFromInitialInputs(selection.project, context); } return null; @@ -163,6 +215,34 @@ export function reconcileProjectSelection( return initialSelection; } + if (!current.project) { + return initialSelection; + } + + if (current.source === "manual") { + const originProject = resolveManualOriginProject(current, context); + if (originProject) { + return { + ...current, + project: originProject, + originProject, + }; + } + } + + const exactProject = resolveExactHostProjectCandidate({ + candidate: current.project, + projects: context.projects, + serverId: context.selectedServerId, + }); + if (exactProject) { + return refreshSelectionProject(current, exactProject); + } + + if (context.shouldPreserveMissingProject(current.project)) { + return current; + } + const resolvedProject = resolveProjectSelection(current, context); if (resolvedProject) { return refreshSelectionProject(current, resolvedProject); diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 0780c1b6ad..7eb03bda8b 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -31,7 +31,7 @@ import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { useProjects } from "@/hooks/use-projects"; -import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -233,16 +233,16 @@ function ProjectSettingsBody({ () => [ { serverId: selectedHost.serverId, - projectKey: project.projectKey, + projectViewKey: project.viewKey, iconWorkingDir: selectedHost.repoRoot, }, ], - [project.projectKey, selectedHost.repoRoot, selectedHost.serverId], + [project.viewKey, selectedHost.repoRoot, selectedHost.serverId], ); - const projectIconDataByKey = useProjectIconDataByProjectKey({ + const projectIconDataByKey = useProjectIconDataByProjectViewKey({ projects: projectIconTargets, }); - const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null; + const projectIconDataUri = projectIconDataByKey.get(project.viewKey) ?? null; const loadedConfig: PaseoConfigRaw | null = data?.ok ? (data.config ?? {}) : null; const loadedRevision: PaseoConfigRevision | null = data?.ok ? data.revision : null; const readError: ProjectConfigRpcError | null = data && !data.ok ? data.error : null; @@ -262,7 +262,7 @@ function ProjectSettingsBody({ ({ })); vi.mock("@/projects/project-icons", () => ({ - useProjectIconDataByProjectKey: () => new Map(), + useProjectIconDataByProjectViewKey: () => new Map(), })); import ProjectsScreen from "./projects-screen"; @@ -239,7 +239,7 @@ function project(overrides: Partial = {}): ProjectSummary { overrides.totalWorkspaceCount ?? hosts.reduce((sum, host) => sum + host.workspaceCount, 0); const onlineHostCount = overrides.onlineHostCount ?? hosts.filter((h) => h.isOnline).length; return { - projectKey: "remote:github.com/acme/app", + viewKey: "remote:github.com/acme/app", projectName: "acme/app", hosts, totalWorkspaceCount, @@ -323,7 +323,7 @@ describe("ProjectsScreen", () => { it("navigates to the project detail route when the row is pressed", () => { setProjectsState({ - projects: [project({ projectKey: "remote:github.com/acme/app" })], + projects: [project({ viewKey: "remote:github.com/acme/app" })], }); render({ kind: "projects" }); @@ -339,7 +339,7 @@ describe("ProjectsScreen", () => { it("does not render a kebab menu on the row", () => { setProjectsState({ - projects: [project({ projectKey: "remote:github.com/acme/app" })], + projects: [project({ viewKey: "remote:github.com/acme/app" })], }); render({ kind: "projects" }); @@ -388,9 +388,9 @@ describe("ProjectsScreen", () => { it("highlights the selected row when the active view targets a project", () => { setProjectsState({ projects: [ - project({ projectKey: "remote:github.com/acme/app" }), + project({ viewKey: "remote:github.com/acme/app" }), project({ - projectKey: "remote:github.com/acme/other", + viewKey: "remote:github.com/acme/other", projectName: "acme/other", githubUrl: "https://github.com/acme/other", }), diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx index 7dc76e952e..e30d3bc5a8 100644 --- a/packages/app/src/screens/projects-screen.tsx +++ b/packages/app/src/screens/projects-screen.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; -import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; import { settingsStyles } from "@/styles/settings"; import { buildProjectSettingsRoute } from "@/utils/host-routes"; import { getProjectSummaryForHostProject, type ProjectSummary } from "@/utils/projects"; @@ -19,10 +19,9 @@ interface ProjectsScreenProps { export default function ProjectsScreen({ view }: ProjectsScreenProps) { const { t } = useTranslation(); const { projects, hostErrors, isLoading } = useProjects(); - const selectedProjectKey = + const selectedProjectViewKey = view.kind === "project" - ? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.projectKey ?? - null) + ? (getProjectSummaryForHostProject(projects, view.serverId, view.projectId)?.viewKey ?? null) : null; const iconTargets = useMemo( () => @@ -32,14 +31,14 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) { return [ { serverId: host.serverId, - projectKey: project.projectKey, + projectViewKey: project.viewKey, iconWorkingDir: host.repoRoot, }, ]; }), [projects], ); - const iconDataByProjectKey = useProjectIconDataByProjectKey({ + const iconDataByProjectViewKey = useProjectIconDataByProjectViewKey({ projects: iconTargets, }); @@ -65,11 +64,11 @@ export default function ProjectsScreen({ view }: ProjectsScreenProps) { {projects.map((project, index) => ( ))} @@ -103,7 +102,7 @@ interface ProjectRowProps { function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); - const { projectKey, projectName } = project; + const { viewKey, projectName } = project; const target = project.hosts[0]; const handleNavigate = useCallback(() => { if (target) router.navigate(buildProjectSettingsRoute(target.serverId, target.projectId)); @@ -127,7 +126,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro onPress={handleNavigate} accessibilityRole="button" accessibilityLabel={t("settings.projectList.editProject", { projectName })} - testID={`project-row-${projectKey}`} + testID={`project-row-${viewKey}`} data-selected={isSelected ? "true" : "false"} > @@ -135,7 +134,7 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro @@ -150,18 +149,18 @@ function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowPro function ProjectRowIcon({ iconDataUri, projectName, - projectKey, + projectViewKey, }: { iconDataUri: string | null; projectName: string; - projectKey: string; + projectViewKey: string; }) { const initial = projectName.trim().charAt(0).toUpperCase() || "?"; return ( & Pick, ): WorkspaceDescriptor { @@ -114,10 +119,10 @@ function emptySidebarOrder(): SidebarOrderSnapshot { }; } -function selectWorkspaceStructureProjectKeys( +function selectWorkspaceStructureProjectViewKeys( state: Parameters[0], ): string[] { - return selectWorkspaceStructureProjects(state, [SERVER_ID]).map((project) => project.projectKey); + return selectWorkspaceStructureProjects(state, [SERVER_ID]).map((project) => project.viewKey); } afterEach(() => { @@ -348,9 +353,11 @@ describe("workspace structure composition", () => { }; initializeWorkspaces([workspace]); - const emittedProjectKeys = [selectWorkspaceStructureProjectKeys(useSessionStore.getState())]; + const emittedProjectKeys = [ + selectWorkspaceStructureProjectViewKeys(useSessionStore.getState()), + ]; const stop = useSessionStore.subscribe((state) => { - emittedProjectKeys.push(selectWorkspaceStructureProjectKeys(state)); + emittedProjectKeys.push(selectWorkspaceStructureProjectViewKeys(state)); }); try { @@ -360,7 +367,10 @@ describe("workspace structure composition", () => { stop(); } - expect(emittedProjectKeys).toEqual([["project-a"], ["project-a"]]); + expect(emittedProjectKeys).toEqual([ + [equivalenceViewKey("project-a")], + [equivalenceViewKey("project-a")], + ]); }); it("changes for membership updates but not status-only updates", () => { @@ -451,10 +461,13 @@ describe("workspace structure composition", () => { const before = snapshotStructure(SERVER_ID, emptySidebarOrder()); const after = snapshotStructure(SERVER_ID, { ...emptySidebarOrder(), - projectOrder: ["project-b", "project-a"], + projectOrder: [equivalenceViewKey("project-b"), equivalenceViewKey("project-a")], }); - expect(after.projects.map((project) => project.projectKey)).toEqual(["project-b", "project-a"]); + expect(after.projects.map((project) => project.viewKey)).toEqual([ + equivalenceViewKey("project-b"), + equivalenceViewKey("project-a"), + ]); expect(after).not.toEqual(before); }); }); diff --git a/packages/app/src/stores/session-store-hooks/selectors.ts b/packages/app/src/stores/session-store-hooks/selectors.ts index bbc243423a..f911e30d41 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.ts @@ -214,8 +214,7 @@ export function composeWorkspaceStructure(input: { const orderedProjects = applyStoredOrdering({ items: input.projects.map((project) => { - const workspaceOrder = - input.workspaceOrderByScope[project.projectKey] ?? EMPTY_WORKSPACE_KEYS; + const workspaceOrder = input.workspaceOrderByScope[project.viewKey] ?? EMPTY_WORKSPACE_KEYS; return { ...project, workspaceKeys: applyStoredOrdering({ @@ -226,7 +225,7 @@ export function composeWorkspaceStructure(input: { }; }), storedOrder: input.projectOrder, - getKey: (project) => project.projectKey, + getKey: (project) => project.viewKey, }); return { projects: orderedProjects }; diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 8ad128eeee..307e432c0a 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -7,8 +7,8 @@ interface SidebarOrderStoreState { workspaceOrderByProject: Record; getProjectOrder: () => string[]; setProjectOrder: (keys: string[]) => void; - getWorkspaceOrder: (projectKey: string) => string[]; - setWorkspaceOrder: (projectKey: string, keys: string[]) => void; + getWorkspaceOrder: (projectViewKey: string) => string[]; + setWorkspaceOrder: (projectViewKey: string, keys: string[]) => void; } interface SidebarOrderPersistedState { @@ -20,7 +20,7 @@ interface SidebarOrderPersistedState { interface SidebarWorkspaceOrderScope { serverId: string; - projectKey: string; + projectViewKey: string; } function normalizeKeys(keys: string[]): string[] { @@ -43,8 +43,8 @@ function normalizeWorkspaceOrderByProject( workspaceOrderByProject: Record | undefined, ): Record { const normalized: Record = {}; - for (const [projectKey, order] of Object.entries(workspaceOrderByProject ?? {})) { - const scope = projectKey.trim(); + for (const [projectViewKey, order] of Object.entries(workspaceOrderByProject ?? {})) { + const scope = projectViewKey.trim(); if (!scope) continue; normalized[scope] = normalizeKeys(order); } @@ -55,9 +55,9 @@ function extractWorkspaceOrderScope(scopeKey: string): SidebarWorkspaceOrderScop const separatorIndex = scopeKey.indexOf("::"); if (separatorIndex < 0) return null; const serverId = scopeKey.slice(0, separatorIndex).trim(); - const projectKey = scopeKey.slice(separatorIndex + 2).trim(); - if (!serverId || !projectKey) return null; - return { serverId, projectKey }; + const projectViewKey = scopeKey.slice(separatorIndex + 2).trim(); + if (!serverId || !projectViewKey) return null; + return { serverId, projectViewKey }; } function normalizeLegacyWorkspaceKey(serverId: string, rawWorkspaceKey: string): string | null { @@ -91,7 +91,7 @@ export function migrateSidebarOrderState(persistedState: unknown): { for (const [scopeKey, order] of Object.entries(state.workspaceOrderByServerAndProject ?? {})) { const scope = extractWorkspaceOrderScope(scopeKey); if (!scope) continue; - const existing = workspaceOrderByProject[scope.projectKey] ?? []; + const existing = workspaceOrderByProject[scope.projectViewKey] ?? []; const merged = [...existing]; const seen = new Set(merged); for (const key of order) { @@ -100,7 +100,7 @@ export function migrateSidebarOrderState(persistedState: unknown): { seen.add(workspaceKey); merged.push(workspaceKey); } - workspaceOrderByProject[scope.projectKey] = merged; + workspaceOrderByProject[scope.projectViewKey] = merged; } return { projectOrder, workspaceOrderByProject }; @@ -116,13 +116,13 @@ export const useSidebarOrderStore = create()( const normalized = normalizeKeys(keys); set({ projectOrder: normalized }); }, - getWorkspaceOrder: (projectKey) => { - const scope = projectKey.trim(); + getWorkspaceOrder: (projectViewKey) => { + const scope = projectViewKey.trim(); if (!scope) return []; return get().workspaceOrderByProject[scope] ?? []; }, - setWorkspaceOrder: (projectKey, keys) => { - const scope = projectKey.trim(); + setWorkspaceOrder: (projectViewKey, keys) => { + const scope = projectViewKey.trim(); if (!scope) return; const normalized = normalizeKeys(keys); set((state) => ({ diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index c8a41b2975..926b59c302 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import { createProjectViewKey } from "@/projects/workspace-structure"; import type { ProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store"; import { buildProjects, getProjectSummaryForHostProject } from "./projects"; @@ -56,7 +57,7 @@ describe("buildProjects", () => { expect(result.projects).toEqual([ expect.objectContaining({ - projectKey: key, + viewKey: createProjectViewKey({ kind: "equivalence", projectKey: key }), hostCount: 2, onlineHostCount: 1, totalWorkspaceCount: 2, diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index 1e1f48a8b6..7ced12425f 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -27,7 +27,7 @@ export interface ProjectHostEntry { } export interface ProjectSummary { - projectKey: string; + viewKey: string; projectName: string; projectCustomName?: string | null; hosts: ProjectHostEntry[]; @@ -91,7 +91,7 @@ interface HostGroup { } interface ProjectGroup { - projectKey: string; + viewKey: string; projectName: string; projectCustomName: string | null; hostsByServerId: Map; @@ -99,10 +99,10 @@ interface ProjectGroup { function findProjectMetadata( host: ProjectHost, - projectKey: string, + projectId: string, ): { customName: string | null; displayName: string } | null { for (const project of host.projects) { - if (project.projectId === projectKey) { + if (project.projectId === projectId) { return { customName: project.projectCustomName ?? null, displayName: project.projectDisplayName, @@ -172,7 +172,7 @@ function toProjectSummary(draft: ProjectGroup): ProjectSummary { const totalWorkspaceCount = hosts.reduce((sum, host) => sum + host.workspaceCount, 0); const onlineHostCount = hosts.filter((host) => host.isOnline).length; return { - projectKey: draft.projectKey, + viewKey: draft.viewKey, projectName: draft.projectName, projectCustomName: draft.projectCustomName, hosts, @@ -196,15 +196,15 @@ function addHostProjects( if (!placement) continue; const projectId = placement.projectId; const customName = findProjectMetadata(host, projectId); - let group = groups.get(hostProject.projectKey); + let group = groups.get(hostProject.viewKey); if (!group) { group = { - projectKey: hostProject.projectKey, + viewKey: hostProject.viewKey, projectName: customName?.displayName ?? hostProject.projectName, projectCustomName: customName?.customName ?? null, hostsByServerId: new Map(), }; - groups.set(hostProject.projectKey, group); + groups.set(hostProject.viewKey, group); } else if (customName?.customName && !group.projectCustomName) { group.projectCustomName = customName.customName; group.projectName = customName.displayName; @@ -230,16 +230,16 @@ function attachHostWorkspaces( host: ProjectHost, hostProjects: HostProjectListItem[], ): void { - const projectKeyByProjectId = new Map( + const projectViewKeyByProjectId = new Map( hostProjects.flatMap((project) => project.hosts .filter((placement) => placement.serverId === host.serverId && placement.projectId) - .map((placement) => [placement.projectId!, project.projectKey] as const), + .map((placement) => [placement.projectId!, project.viewKey] as const), ), ); for (const workspace of host.workspaces) { - const key = projectKeyByProjectId.get(workspace.projectId); + const key = projectViewKeyByProjectId.get(workspace.projectId); if (key) groups.get(key)?.hostsByServerId.get(host.serverId)?.workspaces.push(workspace); } } @@ -262,7 +262,7 @@ export function buildProjects(input: BuildProjectsInput): BuildProjectsResult { if (name !== 0) { return name; } - return left.projectKey.localeCompare(right.projectKey); + return left.viewKey.localeCompare(right.viewKey); }); return { projects }; diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index a4054fa8c3..429502014c 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -6,6 +6,7 @@ import type { import { buildSidebarProjectRowModel, resolveSidebarProjectIconTarget, + resolveSidebarProjectIconTargets, resolveSidebarProjectLocalPath, } from "./sidebar-project-row-model"; @@ -14,7 +15,7 @@ function workspace(overrides: Partial = {}): SidebarWorks workspaceKey: "srv:ws-root", serverId: "srv", workspaceId: "ws-root", - projectKey: "project-1", + projectViewKey: "project-1", projectName: "paseo", workspaceDirectory: "/repo", projectKind: "git", @@ -48,7 +49,7 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry { (host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }), ); return { - projectKey: "project-1", + viewKey: "project-1", projectName: "paseo", projectKind, iconWorkingDir: "/repo", @@ -209,6 +210,22 @@ describe("buildSidebarProjectRowModel", () => { }); }); + it("keys project icon results by the rendered project view", () => { + const [iconTarget] = resolveSidebarProjectIconTargets([ + project({ + viewKey: '["placement","host-b","project-b"]', + hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }], + }), + ]); + + expect(iconTarget).toEqual({ + projectViewKey: '["placement","host-b","project-b"]', + serverId: "host-b", + projectId: "project-host-b", + iconWorkingDir: "/repo/b", + }); + }); + it("resolves desktop file actions from the local project placement", () => { const groupedProject = project({ iconWorkingDir: "/remote/repo", diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index fac2384e14..0333ca3d10 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -44,7 +44,7 @@ export function resolveSidebarProjectIconTarget( } export interface SidebarProjectIconTarget extends SidebarProjectHostTarget { - projectKey: string; + projectViewKey: string; } export function resolveSidebarProjectIconTargets( @@ -52,7 +52,7 @@ export function resolveSidebarProjectIconTargets( ): SidebarProjectIconTarget[] { return projects.flatMap((project) => { const target = resolveSidebarProjectIconTarget(project); - return target ? [{ projectKey: project.projectKey, ...target }] : []; + return target ? [{ projectViewKey: project.viewKey, ...target }] : []; }); } diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index d5f11d779d..7e91d52433 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -24,7 +24,7 @@ function workspace(input: { workspaceKey: `${input.serverId}:${input.workspaceId}`, serverId: input.serverId, workspaceId: input.workspaceId, - projectKey: input.projectKey ?? "project-default", + projectViewKey: input.projectKey ?? "project-default", projectName: input.projectKey ?? "Project", workspaceDirectory: input.workspaceDirectory, projectKind: "git", @@ -46,7 +46,7 @@ function workspace(input: { function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): SidebarProjectEntry { return { - projectKey, + viewKey: projectKey, projectName: projectKey, projectKind: "git", iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index fd2720076c..86540357e6 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -36,7 +36,7 @@ export function buildSidebarShortcutModel(input: { return buildSidebarShortcutSections({ sections: input.projects.map((project) => ({ workspaces: project.workspaces, - collapsed: input.collapsedProjectKeys.has(project.projectKey), + collapsed: input.collapsedProjectKeys.has(project.viewKey), })), shortcutLimit: input.shortcutLimit, }); From baa1c08ec8a16c19457d3bc23daf8d4a72733f3a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 21:56:11 +0800 Subject: [PATCH 31/46] Update SKILL.md by removing models section Removed models section from SKILL.md and added clarification on provider resolution. --- skills/paseo/SKILL.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 096d8cb09a..428217ecb0 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -73,10 +73,6 @@ Only set feature IDs returned by `inspect_provider`. For Codex fast mode, look f Schedules have the full list/inspect/update/pause/resume/run-once/log/delete surface. Heartbeats deliberately do not. -## Models - -`claude/sonnet` (default), `claude/opus` (harder reasoning), `codex/gpt-5.4` (frontier coding), `claude/haiku` (tests only). - ## Orchestration preferences User-specific configuration at `~/.paseo/orchestration-preferences.json`. **Before any Paseo skill chooses a provider or creates an agent, it must read this file.** Reading means an actual file read, not relying on these examples or defaults. Never hardcode a provider string in another skill — resolve through this file. @@ -105,6 +101,8 @@ Categories: `impl`, `ui`, `research`, `planning`, `audit`. Skills pick the categ If the file is missing, use sensible defaults and tell the user once. +All providers shown in this document are examples, you must resolve real providers either found in the preferences or by calling Paseo provider tooling to find a valid provider. + ## Waiting Agents take time — 10–30+ minutes is routine. Favor asynchronous workflows. From 1ddca07d57b67855e793a0a891cba8136a4586fc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 17:06:10 +0200 Subject: [PATCH 32/46] Show the current pull request after switching branches (#2699) * fix(git): reconcile pull requests after branch changes Treat persisted change-request metadata as a branch-bound hint and prefer current tracking configuration when it identifies the checked-out branch. * fix(git): preserve enterprise fork identity Use branch-bound metadata to supplement the owner when explicit tracking points at the same head on a self-hosted remote. * fix(git): honor local branch upstreams Treat Git remote dot with a merge ref as explicit current tracking so stale PR metadata cannot override a local upstream. * fix(git): own change request branch bindings Centralize persisted hint validation and rebinding so renames preserve change request identity while switches invalidate it, including normalized legacy Enterprise owners. * fix(git): preserve PR identity through workspace renames Resolve the backing worktree root during rename without adding a Git call, and retain fork owner identity from explicit same-name tracking configuration. * fix(git): trust current enterprise fork identity Explicit tracking now derives repository identity from the current remote path, so stale checkout metadata cannot override a repointed fork. * fix(git): recognize enterprise repository aliases GitHub repository identity is case-insensitive. Normalize equality checks so differently cased aliases cannot turn base tracking into a fork lookup. * fix(git): reject ownerless legacy fork matches Owner-qualified legacy hints only belong to their historical owner-prefixed branch names. A later ownerless branch reusing the same head must resolve from current Git state instead. * fix(git): keep fork feature lookups local A feature branch may track the upstream base branch for ancestry even when origin is a fork. Only treat that cross-repository base ref as PR identity when the local branch name identifies the tracked head. --- .../session/checkout/checkout-session.ts | 1 - .../src/server/worktree-core.posix.test.ts | 1 + .../server/src/utils/checkout-git.test.ts | 331 +++++++++++++++++- packages/server/src/utils/checkout-git.ts | 99 +++++- .../server/src/utils/worktree-metadata.ts | 66 +++- packages/server/src/utils/worktree.ts | 10 +- 6 files changed, 484 insertions(+), 24 deletions(-) diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index f3b3ed4dec..e9d58f7629 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -576,7 +576,6 @@ export class CheckoutSession { // Branch is a git fact derived per-descriptor from each workspace's own // live git snapshot (id → cwd); the reconciliation pass re-persists the // `branch` field per workspace from its own cwd. No cwd → ids fan-out here. - // TODO(K10): PR-binding on branch rename is deferred — see plan K10. // Push a workspace_update immediately so the sidebar/header reflect // the new branch name without waiting for the background git watcher. diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 2d4aaf87d5..40bd983c7b 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -879,6 +879,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => { expect(metadata?.changeRequestLookupTarget).toEqual({ headRef: "feature/gitlab-mr", changeRequestNumber: 14, + localBranchName: "feature/gitlab-mr-1", }); expect(facts).toMatchObject({ isGit: true, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 110d3599f6..8a2f879e19 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -89,7 +89,11 @@ function createLegacyWorktreeForTest( paseoHome: options.paseoHome, }); } -import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; +import { + getPaseoWorktreeMetadataPath, + readPaseoWorktreeMetadata, + writePaseoWorktreeMetadata, +} from "./worktree-metadata.js"; function initRepo(): { tempDir: string; repoDir: string } { const tempDir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-git-test-"))); @@ -2480,6 +2484,287 @@ const x = 1; expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/); }); + it("reconciles a PR worktree lookup from current branch tracking", async () => { + execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["branch", "contributor/old-change"], { cwd: repoDir }); + execFileSync("git", ["branch", "new-change"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.new-change.remote", "origin"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/new-change"], { + cwd: repoDir, + }); + const workspaceDir = join(paseoHome, "worktrees", "repo", "pr-worktree"); + mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); + execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { + cwd: repoDir, + }); + const staleLookupTarget = { + headRef: "old-change", + headRepositoryOwner: "contributor", + changeRequestNumber: 41, + }; + writePaseoWorktreeMetadata(workspaceDir, { + baseRefName: "main", + changeRequestLookupTarget: { + ...staleLookupTarget, + localBranchName: "contributor/old-change", + }, + }); + + expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({ + headRef: "old-change", + headRepositoryOwner: "contributor", + }); + + execFileSync("git", ["checkout", "new-change"], { cwd: workspaceDir }); + startGitCommandMetrics(); + const switchedTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome); + const commands = stopGitCommandMetrics().commands.map((command) => command.args[0]); + + expect(switchedTarget).toMatchObject({ headRef: "new-change" }); + expect(switchedTarget).not.toHaveProperty("headRepositoryOwner"); + expect(commands).not.toContain("fetch"); + + writePaseoWorktreeMetadata(workspaceDir, { + baseRefName: "main", + changeRequestLookupTarget: { + ...staleLookupTarget, + localBranchName: "new-change", + }, + }); + expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({ + headRef: "new-change", + }); + + execFileSync( + "git", + ["remote", "add", "enterprise-fork", "git@github.acme.internal:contributor/repo.git"], + { + cwd: repoDir, + }, + ); + execFileSync("git", ["config", "branch.new-change.remote", "enterprise-fork"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/old-change"], { + cwd: repoDir, + }); + expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({ + headRef: "old-change", + headRepositoryOwner: "contributor", + }); + + execFileSync("git", ["branch", "local-upstream"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.new-change.remote", "."], { cwd: repoDir }); + execFileSync("git", ["config", "branch.new-change.merge", "refs/heads/local-upstream"], { + cwd: repoDir, + }); + expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({ + headRef: "local-upstream", + }); + }); + + it("does not apply ambiguous legacy PR metadata to a suffixed branch", async () => { + execFileSync("git", ["branch", "contributor/old-change-1"], { cwd: repoDir }); + const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-pr-worktree"); + mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); + execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change-1"], { + cwd: repoDir, + }); + writePaseoWorktreeMetadata(workspaceDir, { + baseRefName: "main", + changeRequestLookupTarget: { + headRef: "old-change", + headRepositoryOwner: "contributor", + changeRequestNumber: 41, + }, + }); + + const lookupTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome); + + expect(lookupTarget).toMatchObject({ headRef: "contributor/old-change-1" }); + expect(lookupTarget).not.toHaveProperty("headRepositoryOwner"); + }); + + it("does not apply a legacy fork hint to an ownerless branch with the same head", async () => { + execFileSync("git", ["branch", "contributor/old-change"], { cwd: repoDir }); + execFileSync("git", ["branch", "old-change"], { cwd: repoDir }); + const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-fork-worktree"); + mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); + execFileSync("git", ["worktree", "add", workspaceDir, "contributor/old-change"], { + cwd: repoDir, + }); + writePaseoWorktreeMetadata(workspaceDir, { + baseRefName: "main", + changeRequestLookupTarget: { + headRef: "old-change", + headRepositoryOwner: "contributor", + changeRequestNumber: 41, + }, + }); + const requestedTargets: RequestedPullRequestTarget[] = []; + const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets }); + + const forkFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); + await getPullRequestStatus( + workspaceDir, + forge, + { force: true, reason: "legacy-fork-branch" }, + { paseoHome, facts: forkFacts }, + ); + expect(requestedTargets.at(-1)).toMatchObject({ + headRef: "old-change", + headRepositoryOwner: "contributor", + }); + + execFileSync("git", ["checkout", "old-change"], { cwd: workspaceDir }); + const ownerlessFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); + await getPullRequestStatus( + workspaceDir, + forge, + { force: true, reason: "ownerless-same-head" }, + { paseoHome, facts: ownerlessFacts }, + ); + + expect(requestedTargets.at(-1)).toMatchObject({ headRef: "old-change" }); + expect(requestedTargets.at(-1)).not.toHaveProperty("headRepositoryOwner"); + }); + + it("recognizes a normalized GitHub owner branch from legacy Enterprise metadata", async () => { + execFileSync("git", ["remote", "add", "origin", "git@github.acme.internal:base/repo.git"], { + cwd: repoDir, + }); + execFileSync( + "git", + ["remote", "add", "enterprise-fork", "git@github.acme.internal:MixedOwner/repo.git"], + { + cwd: repoDir, + }, + ); + execFileSync("git", ["branch", "mixedowner/old-change"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.mixedowner/old-change.remote", "enterprise-fork"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.mixedowner/old-change.merge", "refs/heads/old-change"], { + cwd: repoDir, + }); + const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-enterprise-worktree"); + mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); + execFileSync("git", ["worktree", "add", workspaceDir, "mixedowner/old-change"], { + cwd: repoDir, + }); + writePaseoWorktreeMetadata(workspaceDir, { + baseRefName: "main", + changeRequestLookupTarget: { + headRef: "old-change", + headRepositoryOwner: "MixedOwner", + changeRequestNumber: 41, + }, + }); + const requestedTargets: RequestedPullRequestTarget[] = []; + const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets }); + + const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); + await getPullRequestStatus( + workspaceDir, + forge, + { force: true, reason: "legacy-enterprise-owner" }, + { paseoHome, facts }, + ); + + expect(requestedTargets).toEqual([ + expect.objectContaining({ headRef: "old-change", headRepositoryOwner: "MixedOwner" }), + ]); + + execFileSync( + "git", + ["remote", "add", "replacement-fork", "git@github.acme.internal:OtherOwner/repo.git"], + { cwd: repoDir }, + ); + execFileSync("git", ["config", "branch.mixedowner/old-change.remote", "replacement-fork"], { + cwd: repoDir, + }); + const repointedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); + await getPullRequestStatus( + workspaceDir, + forge, + { force: true, reason: "repointed-enterprise-owner" }, + { paseoHome, facts: repointedFacts }, + ); + + expect(requestedTargets.at(-1)).toMatchObject({ + headRef: "old-change", + headRepositoryOwner: "OtherOwner", + }); + }); + + it("keeps a ref-only change request bound across rename but not branch switch", async () => { + execFileSync("git", ["branch", "feature/gitlab-mr"], { cwd: repoDir }); + const workspaceDir = join(paseoHome, "worktrees", "repo", "gitlab-mr-worktree"); + mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true }); + execFileSync("git", ["worktree", "add", workspaceDir, "feature/gitlab-mr"], { + cwd: repoDir, + }); + writePaseoWorktreeMetadata(workspaceDir, { + baseRefName: "main", + changeRequestLookupTarget: { + headRef: "feature/gitlab-mr", + changeRequestNumber: 14, + localBranchName: "feature/gitlab-mr", + }, + }); + const workspaceCwd = join(workspaceDir, "packages", "service"); + mkdirSync(workspaceCwd, { recursive: true }); + const requestedTargets: RequestedPullRequestTarget[] = []; + const forge = createGitHubServiceRecordingPullRequestTargets({ requestedTargets }); + + await renameCurrentBranch(workspaceCwd, "feature/renamed"); + expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toMatchObject({ + localBranchName: "feature/renamed", + }); + const renamedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); + const renamedStatus = await getPullRequestStatus( + workspaceDir, + forge, + { force: true, reason: "renamed-change-request" }, + { paseoHome, facts: renamedFacts }, + ); + + expect(requestedTargets).toEqual([expect.objectContaining({ headRef: "feature/gitlab-mr" })]); + expect(renamedStatus.status?.headRefName).toBe("feature/gitlab-mr"); + + execFileSync("git", ["checkout", "-b", "other-branch"], { cwd: workspaceDir }); + const switchedFacts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome }); + await getPullRequestStatus( + workspaceDir, + forge, + { force: true, reason: "switched-after-rename" }, + { paseoHome, facts: switchedFacts }, + ); + + expect(requestedTargets.at(-1)).toMatchObject({ headRef: "other-branch" }); + }); + + it("keeps fork identity when the local and tracked branch names match", async () => { + execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["remote", "add", "contributor", "git@github.com:contributor/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["checkout", "-b", "topic"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.topic.remote", "contributor"], { cwd: repoDir }); + execFileSync("git", ["config", "branch.topic.merge", "refs/heads/topic"], { cwd: repoDir }); + + const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); + + expect(lookupTarget).toMatchObject({ + headRef: "topic", + headRepositoryOwner: "contributor", + }); + }); + it("does not attach an owner when the tracked remote is the same GitHub repository", async () => { execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { @@ -2571,6 +2856,50 @@ const x = 1; expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/); }); + it("keeps the local branch lookup when a fork tracks the upstream base branch", async () => { + execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "git@github.com:contributor/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["remote", "add", "upstream", "https://github.com/getpaseo/paseo.git"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.local-feature.remote", "upstream"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.local-feature.merge", "refs/heads/main"], { + cwd: repoDir, + }); + + const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); + + expect(lookupTarget).toMatchObject({ headRef: "local-feature" }); + expect(lookupTarget).not.toHaveProperty("headRepositoryOwner"); + }); + + it("treats differently cased Enterprise remotes as the same repository", async () => { + execFileSync("git", ["checkout", "-b", "local-feature"], { cwd: repoDir }); + execFileSync("git", ["remote", "add", "origin", "git@github.acme.internal:Acme/Repo.git"], { + cwd: repoDir, + }); + execFileSync( + "git", + ["remote", "add", "enterprise-alias", "git@github.acme.internal:acme/repo.git"], + { cwd: repoDir }, + ); + execFileSync("git", ["config", "branch.local-feature.remote", "enterprise-alias"], { + cwd: repoDir, + }); + execFileSync("git", ["config", "branch.local-feature.merge", "refs/heads/main"], { + cwd: repoDir, + }); + + const lookupTarget = await readPullRequestLookupTargetFromFacts(repoDir, paseoHome); + + expect(lookupTarget).toMatchObject({ headRef: "local-feature" }); + expect(lookupTarget).not.toHaveProperty("headRepositoryOwner"); + }); + it("derives the same origin tracked head for on-demand PR status reads", async () => { execFileSync("git", ["checkout", "-b", "tender-parrot"], { cwd: repoDir }); execFileSync("git", ["remote", "add", "origin", "https://github.com/getpaseo/paseo.git"], { diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d2e7ae29cc..77763f9212 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; +import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { maxBase64EncryptedPlaintextByteLength } from "@getpaseo/relay"; import type { Logger } from "pino"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; @@ -28,7 +29,12 @@ import { import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { runGitCommand } from "./run-git-command.js"; import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js"; -import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata } from "./worktree-metadata.js"; +import { + getPaseoWorktreeChangeRequestHintForBranch, + type PaseoWorktreeMetadata, + readPaseoWorktreeMetadata, + rebindPaseoWorktreeChangeRequestHint, +} from "./worktree-metadata.js"; const READ_ONLY_GIT_ENV = { GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C", @@ -827,6 +833,22 @@ async function requireGitRepo(cwd: string): Promise { } } +async function requireGitWorktreeRoot(cwd: string): Promise { + try { + const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + }); + const worktreeRoot = parseGitRevParsePath(stdout); + if (!worktreeRoot) { + throw new Error("Git returned no worktree root"); + } + return worktreeRoot; + } catch { + throw new NotGitRepoError(cwd); + } +} + export async function getCurrentBranch(cwd: string): Promise { try { const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], { @@ -1037,7 +1059,7 @@ export async function renameCurrentBranch( cwd: string, newName: string, ): Promise<{ previousBranch: string | null; currentBranch: string | null }> { - await requireGitRepo(cwd); + const worktreeRoot = await requireGitWorktreeRoot(cwd); const previousBranch = await getCurrentBranch(cwd); if (!previousBranch || previousBranch === "HEAD") { @@ -1050,6 +1072,9 @@ export async function renameCurrentBranch( }); const currentBranch = await getCurrentBranch(cwd); + if (currentBranch) { + rebindPaseoWorktreeChangeRequestHint(worktreeRoot, previousBranch, currentBranch); + } return { previousBranch, currentBranch }; } @@ -1600,22 +1625,25 @@ function buildPullRequestLookupTargetFromBranchConfig( input: PullRequestLookupTargetBranchConfig, ): PullRequestStatusLookupTarget { const trackedHeadRef = parseBranchMergeHeadRef(input.branchMergeRef); - if (!input.branchRemoteName || !trackedHeadRef || trackedHeadRef === input.currentBranch) { + if (!input.branchRemoteName || !trackedHeadRef) { return { headRef: input.currentBranch }; } - const remoteRepo = input.branchRemoteUrl - ? parseGitHubRepoFromRemote(input.branchRemoteUrl) - : null; - const originRepo = input.originRemoteUrl - ? parseGitHubRepoFromRemote(input.originRemoteUrl) - : null; - const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo); + const remoteRepo = parseRepositoryIdentityFromRemote(input.branchRemoteUrl); + const originRepo = parseRepositoryIdentityFromRemote(input.originRemoteUrl); + const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo); const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null; const normalizedBaseRef = input.resolvedBaseRef ? normalizeLocalBranchRefName(input.resolvedBaseRef) : null; - if (trackedHeadRef === normalizedBaseRef && !headRepositoryOwner) { + if ( + trackedHeadRef === normalizedBaseRef && + !doesLocalBranchNameIdentifyTrackedHead( + input.currentBranch, + trackedHeadRef, + headRepositoryOwner, + ) + ) { return { headRef: input.currentBranch }; } @@ -1629,6 +1657,34 @@ function buildPullRequestLookupTargetFromBranchConfig( }; } +function parseRepositoryIdentityFromRemote(remoteUrl: string | null): string | null { + if (!remoteUrl) { + return null; + } + const location = parseGitRemoteLocation(remoteUrl); + return location ? (parseGitHubRemoteIdentity(location.path)?.repo ?? null) : null; +} + +function areSameGitHubRepository(left: string | null, right: string | null): boolean { + return Boolean(left && right && left.toLowerCase() === right.toLowerCase()); +} + +function doesLocalBranchNameIdentifyTrackedHead( + currentBranch: string, + trackedHeadRef: string, + headRepositoryOwner: string | null, +): boolean { + if (currentBranch === trackedHeadRef) { + return true; + } + if (!headRepositoryOwner) { + return false; + } + return [headRepositoryOwner, headRepositoryOwner.toLowerCase()].some( + (owner) => currentBranch === `${owner}/${trackedHeadRef}`, + ); +} + function buildPullRequestLookupTargetFromPushConfig( input: PullRequestLookupTargetPushConfig, ): PullRequestStatusLookupTarget | null { @@ -1641,7 +1697,7 @@ function buildPullRequestLookupTargetFromPushConfig( const originRepo = input.originRemoteUrl ? parseGitHubRepoFromRemote(input.originRemoteUrl) : null; - const isSameRepo = Boolean(remoteRepo && originRepo && remoteRepo === originRepo); + const isSameRepo = areSameGitHubRepository(remoteRepo, originRepo); const headRepositoryOwner = remoteRepo && !isSameRepo ? remoteRepo.split("/")[0] : null; const normalizedBaseRef = input.resolvedBaseRef ? normalizeLocalBranchRefName(input.resolvedBaseRef) @@ -1658,8 +1714,9 @@ function buildPullRequestLookupTargetFromPushConfig( function buildPullRequestLookupTargetFromMetadata( metadata: PaseoWorktreeMetadata | null, + currentBranch: string, ): PullRequestStatusLookupTarget | null { - const target = metadata?.changeRequestLookupTarget; + const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, currentBranch); if (!target) { return null; } @@ -1682,8 +1739,22 @@ function buildInitialPullRequestLookupTarget(input: { return null; } + const hasConfiguredBranchTarget = Boolean( + input.branchRemoteName && parseBranchMergeHeadRef(input.branchMergeRef), + ); + if (hasConfiguredBranchTarget) { + return buildPullRequestLookupTargetFromBranchConfig({ + currentBranch: input.currentBranch, + branchRemoteName: input.branchRemoteName, + branchMergeRef: input.branchMergeRef, + branchRemoteUrl: input.branchRemoteUrl, + originRemoteUrl: input.originRemoteUrl, + resolvedBaseRef: input.resolvedBaseRef, + }); + } + return ( - buildPullRequestLookupTargetFromMetadata(input.metadata) ?? + buildPullRequestLookupTargetFromMetadata(input.metadata, input.currentBranch) ?? buildPullRequestLookupTargetFromBranchConfig({ currentBranch: input.currentBranch, branchRemoteName: input.branchRemoteName, diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index 4300c4370e..931a29966d 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -6,6 +6,7 @@ const ChangeRequestLookupTargetSchema = z.object({ headRef: z.string().min(1), headRepositoryOwner: z.string().min(1).optional(), changeRequestNumber: z.number().int().positive().optional(), + localBranchName: z.string().min(1).optional(), }); const PaseoWorktreeMetadataV1Schema = z.object({ @@ -44,9 +45,66 @@ const PaseoWorktreeMetadataSchema = z.union([ ]); export type PaseoWorktreeMetadata = z.infer; -export type PaseoWorktreeChangeRequestLookupTarget = z.infer< - typeof ChangeRequestLookupTargetSchema ->; +export type PaseoWorktreeChangeRequestHint = z.infer; + +export function createPaseoWorktreeChangeRequestHint( + input: PaseoWorktreeChangeRequestHint, +): PaseoWorktreeChangeRequestHint { + return ChangeRequestLookupTargetSchema.parse(input); +} + +export function getPaseoWorktreeChangeRequestHintForBranch( + metadata: PaseoWorktreeMetadata | null, + currentBranch: string, +): PaseoWorktreeChangeRequestHint | null { + const target = metadata?.changeRequestLookupTarget; + if (!target) { + return null; + } + if (target.localBranchName) { + return target.localBranchName === currentBranch ? target : null; + } + + // COMPAT(change-request-local-branch): metadata before v0.2.5 omitted the + // local binding; remove after 2027-07-31. + const canonicalBranches = new Set(); + if (target.headRepositoryOwner) { + canonicalBranches.add(`${target.headRepositoryOwner}/${target.headRef}`); + const normalizedOwner = normalizeLegacyGitHubOwnerForBranch(target.headRepositoryOwner); + if (normalizedOwner) { + canonicalBranches.add(`${normalizedOwner}/${target.headRef}`); + } + } else { + canonicalBranches.add(target.headRef); + } + return canonicalBranches.has(currentBranch) ? target : null; +} + +function normalizeLegacyGitHubOwnerForBranch(owner: string): string | null { + const normalized = owner.trim().toLowerCase(); + return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; +} + +export function rebindPaseoWorktreeChangeRequestHint( + worktreeRoot: string, + previousBranch: string, + currentBranch: string, +): boolean { + const metadata = readPaseoWorktreeMetadata(worktreeRoot); + const target = getPaseoWorktreeChangeRequestHintForBranch(metadata, previousBranch); + if (!metadata || !target) { + return false; + } + + writePaseoWorktreeMetadataFile(worktreeRoot, { + ...metadata, + changeRequestLookupTarget: { + ...target, + localBranchName: currentBranch, + }, + }); + return true; +} function getGitDirForWorktreeRoot(worktreeRoot: string): string { const gitPath = join(worktreeRoot, ".git"); @@ -90,7 +148,7 @@ export function writePaseoWorktreeMetadata( worktreeRoot: string, options: { baseRefName: string; - changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget; + changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint; }, ): void { const baseRefName = normalizeBaseRefName(options.baseRefName); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 4ab641806b..45fe7fa103 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -22,8 +22,9 @@ export { } from "@getpaseo/protocol/paseo-config-schema"; import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema"; import { + createPaseoWorktreeChangeRequestHint, normalizeBaseRefName, - type PaseoWorktreeChangeRequestLookupTarget, + type PaseoWorktreeChangeRequestHint, readPaseoWorktreeMetadata, readPaseoWorktreeRuntimePort, writePaseoWorktreeMetadata, @@ -1300,7 +1301,7 @@ interface ResolveWorktreeSourcePlanOptions { interface WorktreeSourcePlan { branchName: string; metadataBaseRefName: string; - changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget; + changeRequestLookupTarget?: PaseoWorktreeChangeRequestHint; addArguments: string[]; pushRemote?: { name: string; @@ -1408,13 +1409,14 @@ async function resolveWorktreeSourcePlan({ return { branchName: localBranchName, metadataBaseRefName: normalizedBaseRefName, - changeRequestLookupTarget: { + changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({ headRef: source.headRef, ...(source.headRepositoryOwner ? { headRepositoryOwner: source.headRepositoryOwner } : {}), changeRequestNumber, - }, + localBranchName, + }), addArguments: [localBranchName], ...remotePlan, }; From 35f985baff9667645b05bcde1cf92b9ee18a181c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20K=C4=99drzy=C5=84ski?= Date: Fri, 31 Jul 2026 17:37:04 +0200 Subject: [PATCH 33/46] Switch reasoning, mode, plan, and fast from the Command Center (#2274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Switch reasoning, mode, plan, and fast from the Command Center Extend the Command Center (Cmd-K) with selectors for a focused agent's Thinking (reasoning effort), access Mode, Plan mode, and Fast mode, mirroring the model switcher. Typing surfaces tiered " › " breadcrumb rows with icons and a check on the current value, for both a running agent and a new draft tab. Rows only appear while typing, so the default palette is unchanged. - Thinking and Mode are multi-option selects; Plan mode and Fast mode are On/Off toggles. - Plan mode is dual-natured: a permission mode for Claude/Copilot/OpenCode (excluded from the Mode list, given its own toggle) and the plan_mode feature for Codex. Fast mode drives the fast_mode feature (model-gated). - All logic lives in command-center/agent-setting-contributions.ts as pure, unit-tested builders, with a small icon helper and a hook that registers the running-agent selectors; the draft registers from its composer tab. No changes to the command palette component and no protocol changes. Co-Authored-By: Claude Opus 4.8 * Unify Command Center agent controls * refactor: clarify agent control ownership * refactor(app): tighten command center agent controls --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Mohamed Boudra --- .../e2e/command-center-agent-controls.spec.ts | 110 +++++ .../helpers/command-center-agent-controls.ts | 95 +++++ packages/app/e2e/helpers/command-center.ts | 5 + packages/app/e2e/helpers/seed-client.ts | 1 + packages/app/src/agent-controls/icons.ts | 65 +++ .../app/src/agent-controls/labels.test.ts | 32 ++ packages/app/src/agent-controls/labels.ts | 45 ++ .../app/src/agent-controls/policy.test.ts | 37 ++ packages/app/src/agent-controls/policy.ts | 19 + .../agent-control-contributions.test.ts | 400 ++++++++++++++++++ .../agent-control-contributions.ts | 319 ++++++++++++++ .../agent-control-registration.tsx | 156 +++++++ .../app/src/command-center/command-center.tsx | 21 +- packages/app/src/command-center/icon.tsx | 19 + .../model-contributions.test.tsx | 104 ----- .../src/command-center/model-contributions.ts | 51 --- .../app/src/command-center/provider-icon.tsx | 20 - .../schedules/schedule-form-sheet.tsx | 2 +- .../src/composer/agent-controls/control.tsx | 10 +- .../app/src/composer/agent-controls/index.tsx | 98 +++-- .../composer/agent-controls/mode-control.tsx | 68 +-- .../src/composer/agent-controls/utils.test.ts | 32 -- .../app/src/composer/agent-controls/utils.ts | 50 +-- .../app/src/composer/draft/workspace-tab.tsx | 66 +-- packages/app/src/i18n/resources.test.ts | 6 + packages/app/src/i18n/resources/ar.ts | 10 + packages/app/src/i18n/resources/en.ts | 10 + packages/app/src/i18n/resources/es.ts | 10 + packages/app/src/i18n/resources/fr.ts | 10 + packages/app/src/i18n/resources/ja.ts | 10 + packages/app/src/i18n/resources/pt-BR.ts | 10 + packages/app/src/i18n/resources/ru.ts | 10 + packages/app/src/i18n/resources/zh-CN.ts | 10 + .../app/src/schedules/schedule-form-model.ts | 2 +- 34 files changed, 1526 insertions(+), 387 deletions(-) create mode 100644 packages/app/e2e/command-center-agent-controls.spec.ts create mode 100644 packages/app/e2e/helpers/command-center-agent-controls.ts create mode 100644 packages/app/src/agent-controls/icons.ts create mode 100644 packages/app/src/agent-controls/labels.test.ts create mode 100644 packages/app/src/agent-controls/labels.ts create mode 100644 packages/app/src/agent-controls/policy.test.ts create mode 100644 packages/app/src/agent-controls/policy.ts create mode 100644 packages/app/src/command-center/agent-control-contributions.test.ts create mode 100644 packages/app/src/command-center/agent-control-contributions.ts create mode 100644 packages/app/src/command-center/agent-control-registration.tsx create mode 100644 packages/app/src/command-center/icon.tsx delete mode 100644 packages/app/src/command-center/model-contributions.test.tsx delete mode 100644 packages/app/src/command-center/model-contributions.ts delete mode 100644 packages/app/src/command-center/provider-icon.tsx diff --git a/packages/app/e2e/command-center-agent-controls.spec.ts b/packages/app/e2e/command-center-agent-controls.spec.ts new file mode 100644 index 0000000000..77479aeffd --- /dev/null +++ b/packages/app/e2e/command-center-agent-controls.spec.ts @@ -0,0 +1,110 @@ +import { test } from "./fixtures"; +import { closeCommandCenter, openCommandCenter } from "./helpers/command-center"; +import { + applyCommandCenterAgentControls, + chooseCommandCenterAgentControl, + expectCommandCenterAgentControlSelected, + expectFocusedAgentControls, + expectWorkspaceAgentConfiguration, + submitDraftAgent, + waitForDraftComposer, +} from "./helpers/command-center-agent-controls"; +import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { seedWorkspace } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; + +const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; + +async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise { + await page.addInitScript( + ({ preferencesKey, serverId }) => { + localStorage.setItem( + preferencesKey, + JSON.stringify({ + serverId, + provider: "mock", + providerPreferences: { + mock: { + model: "five-minute-stream", + mode: "load-test", + }, + }, + }), + ); + }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() }, + ); +} + +test.describe("Command Center agent controls", () => { + test.describe.configure({ timeout: 180_000 }); + + test("changes a running agent setting and preserves its selected row", async ({ page }) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "command-center-live-controls-", + title: "Command Center live controls", + model: "five-minute-stream", + }); + try { + // A stale runtime mode makes selecting the provider's supported mode + // exercise the live setting RPC instead of the selected-row no-op. + await workspace.client.setAgentMode(workspace.agentId, "legacy-mode"); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + await openCommandCenter(page); + await chooseCommandCenterAgentControl({ + page, + query: "load test", + choice: "Mode › Load test", + }); + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "five-minute-stream", + modeId: "load-test", + }); + await openCommandCenter(page); + await expectCommandCenterAgentControlSelected({ + page, + query: "load test", + choice: "Mode › Load test", + }); + } finally { + await workspace.cleanup(); + } + }); + + test("applies draft model and setting choices to the created agent", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" }); + await seedMockDraftPreferences(page); + + try { + await openDraftComposer(page, workspace.workspaceId); + await applyCommandCenterAgentControls(page, DRAFT_AGENT_CONTROL_CHOICES); + await closeCommandCenter(page); + await submitDraftAgent(page, "Create an agent with Command Center draft choices"); + await expectFocusedAgentControls(page, DRAFT_AGENT_CONTROL_CHOICES); + await expectWorkspaceAgentConfiguration(workspace, { + provider: "mock", + model: "ten-second-stream", + modeId: "load-test", + }); + } finally { + await workspace.cleanup(); + } + }); +}); + +const DRAFT_AGENT_CONTROL_CHOICES = [ + { query: "ten second stream", choice: "Model › Mock Load Test › Ten second stream" }, + { query: "load test", choice: "Mode › Load test" }, +] as const; + +async function openDraftComposer(page: import("@playwright/test").Page, workspaceId: string) { + await gotoWorkspace(page, workspaceId); + await clickNewChat(page); + await waitForDraftComposer(page); +} diff --git a/packages/app/e2e/helpers/command-center-agent-controls.ts b/packages/app/e2e/helpers/command-center-agent-controls.ts new file mode 100644 index 0000000000..4b4cbfa4d9 --- /dev/null +++ b/packages/app/e2e/helpers/command-center-agent-controls.ts @@ -0,0 +1,95 @@ +import { expect, type Page } from "@playwright/test"; +import type { SeededWorkspace } from "./seed-client"; +import { openCommandCenter } from "./command-center"; + +export interface CommandCenterAgentControlChoice { + query: string; + choice: string; +} + +export async function chooseCommandCenterAgentControl(input: { + page: Page; + query: string; + choice: string; +}): Promise { + const panel = input.page.getByTestId("command-center-panel"); + await panel.getByRole("textbox").fill(input.query); + const choice = panel.getByRole("button", { name: input.choice }); + await expect(choice).toBeVisible({ timeout: 30_000 }); + await choice.click(); +} + +export async function expectCommandCenterAgentControlSelected(input: { + page: Page; + query: string; + choice: string; +}): Promise { + const panel = input.page.getByTestId("command-center-panel"); + await panel.getByRole("textbox").fill(input.query); + const choice = panel.getByRole("button", { name: input.choice }); + await expect(choice).toHaveRole("button"); + await expect(choice).toHaveAttribute("aria-pressed", "true"); +} + +export async function applyCommandCenterAgentControls( + page: Page, + choices: readonly CommandCenterAgentControlChoice[], +): Promise { + for (const choice of choices) { + await openCommandCenter(page); + await chooseCommandCenterAgentControl({ page, ...choice }); + } +} + +export async function expectFocusedAgentControls( + page: Page, + choices: readonly CommandCenterAgentControlChoice[], +): Promise { + await openCommandCenter(page); + for (const choice of choices) { + await expectCommandCenterAgentControlSelected({ page, ...choice }); + } +} + +export async function submitDraftAgent(page: Page, prompt: string): Promise { + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await expect(composer).toBeEditable({ timeout: 30_000 }); + await composer.fill(prompt); + await composer.press("Enter"); + await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 }); +} + +export async function waitForDraftComposer(page: Page): Promise { + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEditable({ + timeout: 30_000, + }); +} + +export async function expectWorkspaceAgentConfiguration( + workspace: Pick, + expected: { id?: string; provider: string; model: string | null; modeId: string | null }, +): Promise { + await expect + .poll( + async () => { + const entries = (await workspace.client.fetchAgents({ scope: "active" })).entries; + const agent = entries + .map((entry) => entry.agent) + .find( + (candidate) => + candidate.workspaceId === workspace.workspaceId && + (expected.id === undefined || candidate.id === expected.id), + ); + return agent + ? { + id: agent.id, + provider: agent.provider, + model: agent.model, + modeId: agent.currentModeId, + } + : null; + }, + { timeout: 30_000 }, + ) + .toMatchObject(expected); +} diff --git a/packages/app/e2e/helpers/command-center.ts b/packages/app/e2e/helpers/command-center.ts index 4a3a58b568..7677a13639 100644 --- a/packages/app/e2e/helpers/command-center.ts +++ b/packages/app/e2e/helpers/command-center.ts @@ -7,3 +7,8 @@ export async function openCommandCenter(page: Page): Promise { await expect(panel).toBeVisible({ timeout: 30_000 }); return panel; } + +export async function closeCommandCenter(page: Page): Promise { + await page.keyboard.press("Escape"); + await expect(page.getByTestId("command-center-panel")).not.toBeVisible(); +} diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index bcc7b41447..03ed14e2b1 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -129,6 +129,7 @@ export interface SeedDaemonClient { }>; }>; updateAgent(agentId: string, updates: { name?: string }): Promise; + setAgentMode(agentId: string, modeId: string): Promise; waitForAgentUpsert( agentId: string, predicate: (snapshot: { status: string }) => boolean, diff --git a/packages/app/src/agent-controls/icons.ts b/packages/app/src/agent-controls/icons.ts new file mode 100644 index 0000000000..06b2759071 --- /dev/null +++ b/packages/app/src/agent-controls/icons.ts @@ -0,0 +1,65 @@ +import type { ComponentType } from "react"; +import { + Bot, + Brain, + ListTodo, + Settings2, + Shield, + ShieldAlert, + ShieldCheck, + ShieldEllipsis, + ShieldOff, + ShieldPlus, + ShieldQuestionMark, + Zap, +} from "lucide-react-native"; +import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; + +export interface AgentControlIconProps { + size: number; + color: string; +} + +export type AgentControlIcon = ComponentType; + +export const ThinkingIcon = Brain; +export const PlanModeIcon = ListTodo; + +const MODE_ICONS: Record = { + Bot, + Shield, + ShieldAlert, + ShieldCheck, + ShieldEllipsis, + ShieldOff, + ShieldPlus, + ShieldQuestionMark, +}; + +const FEATURE_ICONS: Record = { + "list-todo": ListTodo, + "shield-check": ShieldCheck, + zap: Zap, +}; + +export function getAgentModeIcon( + provider: string, + modeId: string, + providerDefinitions: AgentProviderDefinition[], +): AgentControlIcon { + const icon = getModeVisuals(provider, modeId, providerDefinitions)?.icon; + return (icon ? MODE_ICONS[icon] : undefined) ?? Bot; +} + +export function getAgentModeOptionIcon( + provider: string, + modeId: string, + providerDefinitions: AgentProviderDefinition[], +): AgentControlIcon | undefined { + const icon = getModeVisuals(provider, modeId, providerDefinitions)?.icon; + return icon ? MODE_ICONS[icon] : undefined; +} + +export function getAgentFeatureIcon(icon?: string): AgentControlIcon { + return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2; +} diff --git a/packages/app/src/agent-controls/labels.test.ts b/packages/app/src/agent-controls/labels.test.ts new file mode 100644 index 0000000000..4f1302fd1b --- /dev/null +++ b/packages/app/src/agent-controls/labels.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { formatAgentModeLabel, formatThinkingOptionLabel } from "./labels"; + +describe("formatAgentModeLabel", () => { + it("sentence-cases provider mode labels", () => { + expect(formatAgentModeLabel({ id: "plan", label: "Plan" })).toBe("Plan"); + expect(formatAgentModeLabel({ id: "full-access", label: "Full Access" })).toBe("Full access"); + expect(formatAgentModeLabel({ id: "auto-review", label: "Auto-review" })).toBe("Auto-review"); + expect(formatAgentModeLabel({ id: "read_only", label: "read_only" })).toBe("Read only"); + expect(formatAgentModeLabel({ id: "acceptEdits", label: "acceptEdits" })).toBe("Accept edits"); + }); + + it("splits compact mode ids when no provider label is available", () => { + expect(formatAgentModeLabel({ id: "auto-review" })).toBe("Auto review"); + }); +}); + +describe("formatThinkingOptionLabel", () => { + it("formats compact thinking option labels for display", () => { + expect(formatThinkingOptionLabel({ id: "none", label: "none" })).toBe("None"); + expect(formatThinkingOptionLabel({ id: "low", label: "low" })).toBe("Low"); + expect(formatThinkingOptionLabel({ id: "medium", label: "medium" })).toBe("Medium"); + expect(formatThinkingOptionLabel({ id: "high", label: "high" })).toBe("High"); + expect(formatThinkingOptionLabel({ id: "xhigh", label: "xhigh" })).toBe("Extra high"); + }); + + it("sentence-cases split provider labels", () => { + expect(formatThinkingOptionLabel({ id: "extra_high", label: "extra_high" })).toBe("Extra high"); + expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard"); + expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high"); + }); +}); diff --git a/packages/app/src/agent-controls/labels.ts b/packages/app/src/agent-controls/labels.ts new file mode 100644 index 0000000000..ff32aaf4dc --- /dev/null +++ b/packages/app/src/agent-controls/labels.ts @@ -0,0 +1,45 @@ +import { i18n } from "@/i18n/i18next"; + +interface ControlLabelInput { + id: string; + label?: string | null; +} + +function sentenceCase(value: string): string { + if (!value) { + return value; + } + return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); +} + +function splitCompactLabel(value: string, splitHyphen: boolean): string { + const separatorPattern = splitHyphen ? /[_-]+/g : /_+/g; + + return value + .replace(separatorPattern, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/\s+/g, " ") + .trim(); +} + +function formatControlLabel(option: ControlLabelInput, splitHyphen: boolean): string { + const rawLabel = (option.label ?? option.id).trim(); + return sentenceCase(splitCompactLabel(rawLabel, splitHyphen)); +} + +export function formatAgentModeLabel(mode: ControlLabelInput): string { + return formatControlLabel(mode, mode.label == null); +} + +export function formatThinkingOptionLabel(option: ControlLabelInput): string { + const rawLabel = (option.label ?? option.id).trim(); + const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase(); + const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase(); + + if (compactId === "xhigh" || compactLabel === "xhigh") { + return i18n.t("agentControls.thinking.extraHigh"); + } + + return formatControlLabel(option, true); +} diff --git a/packages/app/src/agent-controls/policy.test.ts b/packages/app/src/agent-controls/policy.test.ts new file mode 100644 index 0000000000..588c2c9a72 --- /dev/null +++ b/packages/app/src/agent-controls/policy.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { AgentMode } from "@getpaseo/protocol/agent-types"; +import { isPlanningAgentMode, resolveNonPlanningModeId } from "./policy"; + +describe("isPlanningAgentMode", () => { + it("prefers planning metadata and recognizes existing provider ids", () => { + expect(isPlanningAgentMode({ id: "research", colorTier: "planning" })).toBe(true); + expect(isPlanningAgentMode({ id: "plan" })).toBe(true); + expect( + isPlanningAgentMode({ + id: "https://agentclientprotocol.com/protocol/session-modes#plan", + }), + ).toBe(true); + expect(isPlanningAgentMode({ id: "default", colorTier: "safe" })).toBe(false); + }); +}); + +describe("resolveNonPlanningModeId", () => { + const modes = [ + { id: "plan", label: "Plan", colorTier: "planning" }, + { id: "default", label: "Default", colorTier: "safe" }, + { id: "full", label: "Full", colorTier: "dangerous" }, + ] satisfies AgentMode[]; + + it("uses a non-planning provider default", () => { + expect(resolveNonPlanningModeId(modes, "full")).toBe("full"); + }); + + it("does not use a planning or stale provider default", () => { + expect(resolveNonPlanningModeId(modes, "plan")).toBe("default"); + expect(resolveNonPlanningModeId(modes, "deleted")).toBe("default"); + }); + + it("returns null when no non-planning mode exists", () => { + expect(resolveNonPlanningModeId([modes[0]], "plan")).toBeNull(); + }); +}); diff --git a/packages/app/src/agent-controls/policy.ts b/packages/app/src/agent-controls/policy.ts new file mode 100644 index 0000000000..827dfadf57 --- /dev/null +++ b/packages/app/src/agent-controls/policy.ts @@ -0,0 +1,19 @@ +import type { AgentMode } from "@getpaseo/protocol/agent-types"; + +export const PLAN_MODE_FEATURE_ID = "plan_mode"; +export const FAST_MODE_FEATURE_ID = "fast_mode"; + +export function isPlanningAgentMode(mode: Pick): boolean { + return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan"); +} + +export function resolveNonPlanningModeId( + modes: readonly AgentMode[], + defaultModeId: string | null, +): string | null { + const defaultMode = modes.find((mode) => mode.id === defaultModeId); + if (defaultMode && !isPlanningAgentMode(defaultMode)) { + return defaultMode.id; + } + return modes.find((mode) => !isPlanningAgentMode(mode))?.id ?? null; +} diff --git a/packages/app/src/command-center/agent-control-contributions.test.ts b/packages/app/src/command-center/agent-control-contributions.test.ts new file mode 100644 index 0000000000..6fe8f91bb6 --- /dev/null +++ b/packages/app/src/command-center/agent-control-contributions.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, it } from "vitest"; +import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; +import type { CommandCenterContribution } from "./contributions"; +import { + buildAgentControlContributions, + type AgentControlContributionSource, +} from "./agent-control-contributions"; + +function TestIcon() { + return null; +} + +const LABELS: AgentControlContributionSource["labels"] = { + model: "Model", + thinking: "Thinking", + mode: "Mode", + planMode: "Plan mode", + fast: "Fast", + on: "On", + off: "Off", + modelKeywords: "model switch", + thinkingKeywords: "reasoning effort", + modeKeywords: "access permission", + planKeywords: "plan planning", + fastKeywords: "fast speed", +}; + +const ICONS: AgentControlContributionSource["icons"] = { + provider: () => TestIcon, + thinking: TestIcon, + planMode: TestIcon, + mode: () => TestIcon, + feature: () => TestIcon, +}; + +function provider(input: { + id: string; + label: string; + state?: "models" | "error"; +}): ProviderSelectorProvider { + if (input.state === "error") { + return { + id: input.id, + label: input.label, + modelSelection: { kind: "error", message: "Unavailable" }, + }; + } + return { + id: input.id, + label: input.label, + modelSelection: { + kind: "models", + rows: [ + { + favoriteKey: `${input.id}:shared`, + provider: input.id, + providerLabel: input.label, + modelId: "shared", + modelLabel: `${input.label} model`, + description: undefined, + }, + ], + }, + }; +} + +function toggleFeature(id: string, value = false, icon?: string): AgentFeature { + return { type: "toggle", id, label: id, value, icon }; +} + +function makeSource( + overrides: { + provider?: string | null; + models?: Partial; + thinking?: Partial; + modes?: Partial>; + features?: Partial; + } = {}, +): AgentControlContributionSource { + return { + serverId: "host", + ownerKey: "agent", + provider: overrides.provider === undefined ? "claude" : overrides.provider, + labels: LABELS, + icons: ICONS, + models: { + providers: [], + selectedProvider: null, + selectedModelId: null, + select: () => undefined, + ...overrides.models, + }, + thinking: { + options: [], + selectedId: null, + select: () => undefined, + ...overrides.thinking, + }, + modes: { + options: [], + selectedId: null, + defaultModeId: null, + select: () => undefined, + ...overrides.modes, + }, + features: { + list: [], + set: () => undefined, + ...overrides.features, + }, + }; +} + +function selected(contribution: CommandCenterContribution): boolean { + return contribution.presentation.kind === "choice" ? contribution.presentation.selected : false; +} + +const THINKING_OPTIONS: AgentSelectOption[] = [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, +]; + +const CLAUDE_MODES: AgentMode[] = [ + { id: "plan", label: "Plan Mode" }, + { id: "default", label: "Always Ask" }, + { id: "acceptEdits", label: "Accept File Edits" }, +]; + +describe("Command Center agent-control contributions", () => { + it("omits mode-derived choices when no mode control is available", () => { + const contributions = buildAgentControlContributions({ + ...makeSource({ + models: { providers: [provider({ id: "claude", label: "Claude" })] }, + features: { list: [toggleFeature("plan_mode", true)] }, + }), + modes: undefined, + }); + + const groups = contributions.map((contribution) => contribution.group); + expect(groups).toContain("models"); + expect(groups).toContain("plan-mode"); + expect(groups).not.toContain("modes"); + }); + + it("builds unique cross-provider model choices and skips unavailable or empty models", () => { + const selections: string[] = []; + const contributions = buildAgentControlContributions( + makeSource({ + provider: null, + models: { + providers: [ + provider({ id: "claude", label: "Claude" }), + provider({ id: "codex", label: "Codex" }), + provider({ id: "unavailable", label: "Unavailable", state: "error" }), + { + id: "empty", + label: "Empty", + modelSelection: { + kind: "models", + rows: [ + { + favoriteKey: "empty:", + provider: "empty", + providerLabel: "Empty", + modelId: "", + modelLabel: "Default", + description: undefined, + }, + ], + }, + }, + ], + selectedProvider: "codex", + selectedModelId: "shared", + select: (selectedProvider, modelId) => { + selections.push(`${selectedProvider}:${modelId}`); + }, + }, + }), + ); + + expect( + contributions.map((contribution) => ({ + id: contribution.id, + selected: selected(contribution), + })), + ).toEqual([ + { id: "models:claude:shared", selected: false }, + { id: "models:codex:shared", selected: true }, + ]); + expect(contributions[0].presentation).toMatchObject({ + path: ["Model", "Claude", "Claude model"], + testId: "command-center-model-host:claude:shared", + }); + + contributions[0].run(); + contributions[1].run(); + expect(selections).toEqual(["claude:shared"]); + }); + + it("publishes thinking choices only when selection is meaningful", () => { + const selections: string[] = []; + const contributions = buildAgentControlContributions( + makeSource({ + thinking: { + options: THINKING_OPTIONS, + selectedId: "high", + select: (id) => { + selections.push(id); + }, + }, + }), + ).filter((contribution) => contribution.group === "thinking"); + + expect(contributions.map((contribution) => contribution.id)).toEqual([ + "thinking:low", + "thinking:high", + "thinking:xhigh", + ]); + expect(contributions.map(selected)).toEqual([false, true, false]); + for (const contribution of contributions) contribution.run(); + expect(selections).toEqual(["low", "xhigh"]); + + const singleOption = buildAgentControlContributions( + makeSource({ + thinking: { + options: [{ id: "high", label: "High" }], + selectedId: "high", + }, + }), + ); + expect(singleOption.filter((contribution) => contribution.group === "thinking")).toEqual([]); + }); + + it("formats access modes, excludes planning modes, and marks the current mode", () => { + const selections: string[] = []; + const contributions = buildAgentControlContributions( + makeSource({ + modes: { + options: CLAUDE_MODES, + selectedId: "default", + defaultModeId: "default", + select: (id) => { + selections.push(id); + }, + }, + }), + ).filter((contribution) => contribution.group === "modes"); + + expect( + contributions.map((contribution) => ({ + id: contribution.id, + path: + contribution.presentation.kind === "choice" ? contribution.presentation.path : undefined, + selected: selected(contribution), + })), + ).toEqual([ + { id: "modes:default", path: ["Mode", "Always ask"], selected: true }, + { id: "modes:acceptEdits", path: ["Mode", "Accept file edits"], selected: false }, + ]); + contributions[1].run(); + expect(selections).toEqual(["acceptEdits"]); + }); + + it("toggles mode-based Plan through the plan mode and provider default", () => { + const selections: string[] = []; + const contributions = buildAgentControlContributions( + makeSource({ + modes: { + options: CLAUDE_MODES, + selectedId: "plan", + defaultModeId: "default", + select: (id) => { + selections.push(id); + }, + }, + }), + ).filter((contribution) => contribution.group === "plan-mode"); + + expect( + contributions.map((contribution) => ({ + id: contribution.id, + selected: selected(contribution), + })), + ).toEqual([ + { id: "plan-mode:on", selected: true }, + { id: "plan-mode:off", selected: false }, + ]); + contributions[1].run(); + expect(selections).toEqual(["default"]); + }); + + it("does not use a planning provider default as the Plan off target", () => { + const selections: string[] = []; + const contributions = buildAgentControlContributions( + makeSource({ + modes: { + options: [ + { id: "research", label: "Research", colorTier: "planning" }, + { id: "default", label: "Default", colorTier: "safe" }, + ], + selectedId: "research", + defaultModeId: "research", + select: (id) => { + selections.push(id); + }, + }, + }), + ).filter((contribution) => contribution.group === "plan-mode"); + + contributions[1].run(); + expect(selections).toEqual(["default"]); + }); + + it("prefers the plan_mode feature and reads its canonical value", () => { + const calls: [string, unknown][] = []; + const contributions = buildAgentControlContributions( + makeSource({ + modes: { + options: CLAUDE_MODES, + selectedId: "default", + defaultModeId: "default", + }, + features: { + list: [toggleFeature("plan_mode", true, "list-todo")], + set: (id, value) => { + calls.push([id, value]); + }, + }, + }), + ).filter((contribution) => contribution.group === "plan-mode"); + + expect(contributions.map(selected)).toEqual([true, false]); + contributions[1].run(); + expect(calls).toEqual([["plan_mode", false]]); + }); + + it("publishes Fast only for an available toggle and ignores unrelated features", () => { + const calls: [string, unknown][] = []; + const contributions = buildAgentControlContributions( + makeSource({ + features: { + list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)], + set: (id, value) => { + calls.push([id, value]); + }, + }, + }), + ); + const fast = contributions.filter((contribution) => contribution.group === "fast-mode"); + + expect(fast.map(selected)).toEqual([false, true]); + expect(contributions.some((contribution) => contribution.id.includes("auto_accept"))).toBe( + false, + ); + fast[0].run(); + expect(calls).toEqual([["fast_mode", true]]); + + const unavailable = buildAgentControlContributions(makeSource()); + expect(unavailable.filter((contribution) => contribution.group === "fast-mode")).toEqual([]); + }); + + it("centralizes query-only group ordering between built-in result sections", () => { + const contributions = buildAgentControlContributions( + makeSource({ + models: { + providers: [provider({ id: "claude", label: "Claude" })], + selectedProvider: "claude", + selectedModelId: "shared", + }, + thinking: { options: THINKING_OPTIONS, selectedId: "high" }, + modes: { + options: CLAUDE_MODES, + selectedId: "default", + defaultModeId: "default", + }, + features: { list: [toggleFeature("fast_mode")] }, + }), + ); + const ranks = new Map(); + for (const contribution of contributions) { + ranks.set(contribution.group, contribution.groupRank); + expect(contribution.visibility).toBe("query"); + } + + expect([...ranks.entries()]).toEqual([ + ["models", 1], + ["thinking", 1.1], + ["modes", 1.2], + ["plan-mode", 1.3], + ["fast-mode", 1.4], + ]); + for (const rank of ranks.values()) expect(rank).toBeLessThan(2); + expect(new Set(contributions.map((contribution) => contribution.id)).size).toBe( + contributions.length, + ); + }); +}); diff --git a/packages/app/src/command-center/agent-control-contributions.ts b/packages/app/src/command-center/agent-control-contributions.ts new file mode 100644 index 0000000000..d8f31523e9 --- /dev/null +++ b/packages/app/src/command-center/agent-control-contributions.ts @@ -0,0 +1,319 @@ +import type { + AgentFeature, + AgentMode, + AgentProvider, + AgentSelectOption, +} from "@getpaseo/protocol/agent-types"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; +import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; +import { + FAST_MODE_FEATURE_ID, + isPlanningAgentMode, + PLAN_MODE_FEATURE_ID, + resolveNonPlanningModeId, +} from "@/agent-controls/policy"; +import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; + +const GROUP_RANK = { + models: 1, + thinking: 1.1, + modes: 1.2, + planMode: 1.3, + fastMode: 1.4, +} as const; + +export interface AgentControlContributionLabels { + model: string; + thinking: string; + mode: string; + planMode: string; + fast: string; + on: string; + off: string; + modelKeywords: string; + thinkingKeywords: string; + modeKeywords: string; + planKeywords: string; + fastKeywords: string; +} + +export function buildAgentControlContributionLabels( + t: (key: string) => string, +): AgentControlContributionLabels { + return { + model: t("shell.commandCenter.modelGroupLabel"), + thinking: t("shell.commandCenter.thinkingGroupLabel"), + mode: t("shell.commandCenter.modeGroupLabel"), + planMode: t("shell.commandCenter.planModeGroupLabel"), + fast: t("shell.commandCenter.fastModeGroupLabel"), + on: t("shell.commandCenter.settingOn"), + off: t("shell.commandCenter.settingOff"), + modelKeywords: t("shell.commandCenter.modelSearchKeywords"), + thinkingKeywords: t("shell.commandCenter.thinkingSearchKeywords"), + modeKeywords: t("shell.commandCenter.modeSearchKeywords"), + planKeywords: t("shell.commandCenter.planModeSearchKeywords"), + fastKeywords: t("shell.commandCenter.fastModeSearchKeywords"), + }; +} + +export interface AgentControlContributionIcons { + provider(provider: AgentProvider): CommandCenterIcon; + thinking: CommandCenterIcon; + planMode: CommandCenterIcon; + mode(modeId: string): CommandCenterIcon; + feature(feature: AgentFeature): CommandCenterIcon; +} + +interface CommandCenterChoice { + id: string; + path: readonly string[]; + keywords?: readonly string[]; + icon?: CommandCenterIcon; + selected: boolean; + testId?: string; + select(): void | Promise; +} + +interface CommandCenterChoiceGroup { + id: string; + rank: number; + label: string; + keywords: readonly string[]; + choices: readonly CommandCenterChoice[]; +} + +export interface AgentControlContributionSource { + serverId: string; + ownerKey: string; + provider: string | null; + labels: AgentControlContributionLabels; + icons: AgentControlContributionIcons; + models: { + providers: readonly ProviderSelectorProvider[]; + selectedProvider: string | null; + selectedModelId: string | null; + select(provider: AgentProvider, modelId: string): void | Promise; + }; + thinking: { + options: readonly AgentSelectOption[]; + selectedId: string | null; + select(optionId: string): void | Promise; + }; + modes?: { + options: readonly AgentMode[]; + selectedId: string | null; + defaultModeId: string | null; + select(modeId: string): void | Promise; + }; + features: { + list: readonly AgentFeature[]; + set(featureId: string, value: unknown): void | Promise; + }; +} + +function buildContributions( + groups: readonly CommandCenterChoiceGroup[], +): CommandCenterContribution[] { + const contributions: CommandCenterContribution[] = []; + for (const group of groups) { + for (const [rank, choice] of group.choices.entries()) { + contributions.push({ + id: `${group.id}:${choice.id}`, + group: group.id, + groupRank: group.rank, + rank, + keywords: [...group.keywords, ...(choice.keywords ?? [])], + visibility: "query", + run: () => { + if (!choice.selected) return choice.select(); + }, + presentation: { + kind: "choice", + path: [group.label, ...choice.path], + icon: choice.icon, + selected: choice.selected, + testId: choice.testId, + }, + }); + } + } + return contributions; +} + +function buildModelGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { + const choices: CommandCenterChoice[] = []; + for (const provider of source.models.providers) { + if (provider.modelSelection.kind !== "models") continue; + const agentProvider = provider.id; + const icon = source.icons.provider(agentProvider); + for (const model of provider.modelSelection.rows) { + if (!model.modelId) continue; + const modelId = model.modelId; + choices.push({ + id: `${provider.id}:${modelId}`, + path: [provider.label, model.modelLabel], + keywords: [modelId], + icon, + selected: + source.models.selectedProvider === provider.id && + source.models.selectedModelId === modelId, + testId: `command-center-model-${source.serverId}:${provider.id}:${modelId}`, + select: () => source.models.select(agentProvider, modelId), + }); + } + } + return { + id: "models", + rank: GROUP_RANK.models, + label: source.labels.model, + keywords: [source.labels.modelKeywords], + choices, + }; +} + +function buildThinkingGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { + const choices = + source.thinking.options.length > 1 + ? source.thinking.options.map( + (option): CommandCenterChoice => ({ + id: option.id, + path: [formatThinkingOptionLabel(option)], + icon: source.icons.thinking, + selected: option.id === source.thinking.selectedId, + testId: `command-center-thinking-${source.serverId}:${source.ownerKey}:${option.id}`, + select: () => source.thinking.select(option.id), + }), + ) + : []; + return { + id: "thinking", + rank: GROUP_RANK.thinking, + label: source.labels.thinking, + keywords: [source.labels.thinkingKeywords], + choices, + }; +} + +function buildModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { + const modes = source.modes; + const choices = modes + ? modes.options + .filter((mode) => !isPlanningAgentMode(mode)) + .map( + (mode): CommandCenterChoice => ({ + id: mode.id, + path: [formatAgentModeLabel(mode)], + icon: source.icons.mode(mode.id), + selected: mode.id === modes.selectedId, + testId: `command-center-mode-${source.serverId}:${source.ownerKey}:${mode.id}`, + select: () => modes.select(mode.id), + }), + ) + : []; + return { + id: "modes", + rank: GROUP_RANK.modes, + label: source.labels.mode, + keywords: [source.labels.modeKeywords], + choices, + }; +} + +function toggleChoices(input: { + source: AgentControlContributionSource; + key: "plan" | "fast"; + icon: CommandCenterIcon; + isOn: boolean; + turnOn(): void | Promise; + turnOff(): void | Promise; +}): CommandCenterChoice[] { + const states = [ + { id: "on", label: input.source.labels.on, selected: input.isOn, select: input.turnOn }, + { id: "off", label: input.source.labels.off, selected: !input.isOn, select: input.turnOff }, + ]; + return states.map((state) => ({ + id: state.id, + path: [state.label], + icon: input.icon, + selected: state.selected, + testId: `command-center-${input.key}-${input.source.serverId}:${input.source.ownerKey}:${state.id}`, + select: state.select, + })); +} + +function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { + const planFeature = source.features.list.find( + (feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle", + ); + let choices: CommandCenterChoice[] = []; + if (planFeature?.type === "toggle") { + choices = toggleChoices({ + source, + key: "plan", + icon: source.icons.feature(planFeature), + isOn: planFeature.value, + turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true), + turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false), + }); + } else if (source.modes) { + const modes = source.modes; + const planMode = modes.options.find(isPlanningAgentMode); + const offModeId = resolveNonPlanningModeId(modes.options, modes.defaultModeId); + if (planMode && offModeId) { + choices = toggleChoices({ + source, + key: "plan", + icon: source.icons.planMode, + isOn: modes.selectedId === planMode.id, + turnOn: () => modes.select(planMode.id), + turnOff: () => modes.select(offModeId), + }); + } + } + return { + id: "plan-mode", + rank: GROUP_RANK.planMode, + label: source.labels.planMode, + keywords: [source.labels.planKeywords], + choices, + }; +} + +function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { + const fastFeature = source.features.list.find( + (feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle", + ); + const choices = + fastFeature?.type === "toggle" + ? toggleChoices({ + source, + key: "fast", + icon: source.icons.feature(fastFeature), + isOn: fastFeature.value, + turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true), + turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false), + }) + : []; + return { + id: "fast-mode", + rank: GROUP_RANK.fastMode, + label: source.labels.fast, + keywords: [source.labels.fastKeywords], + choices, + }; +} + +export function buildAgentControlContributions( + source: AgentControlContributionSource, +): CommandCenterContribution[] { + const groups = [buildModelGroup(source)]; + if (source.provider) { + groups.push( + buildThinkingGroup(source), + buildModeGroup(source), + buildPlanModeGroup(source), + buildFastModeGroup(source), + ); + } + return buildContributions(groups); +} diff --git a/packages/app/src/command-center/agent-control-registration.tsx b/packages/app/src/command-center/agent-control-registration.tsx new file mode 100644 index 0000000000..ecc7e8985c --- /dev/null +++ b/packages/app/src/command-center/agent-control-registration.tsx @@ -0,0 +1,156 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import type { + AgentFeature, + AgentMode, + AgentProvider, + AgentSelectOption, +} from "@getpaseo/protocol/agent-types"; +import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; +import { + getAgentFeatureIcon, + getAgentModeIcon, + PlanModeIcon, + ThinkingIcon, +} from "@/agent-controls/icons"; +import { getProviderIcon } from "@/components/provider-icons"; +import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; +import { + buildAgentControlContributions, + buildAgentControlContributionLabels, + type AgentControlContributionSource, +} from "./agent-control-contributions"; +import { getCommandCenterIcon } from "./icon"; +import { useCommandCenterActions } from "./provider"; + +export interface AgentControlCommandCenterSource { + serverId: string; + ownerKey: string; + provider: string | null | undefined; + providerDefinitions: AgentProviderDefinition[]; + models: { + providers: readonly ProviderSelectorProvider[]; + selectedProvider: string | null | undefined; + selectedModelId: string | null | undefined; + select(provider: AgentProvider, modelId: string): void | Promise; + }; + thinking: { + options: readonly AgentSelectOption[] | null | undefined; + selectedId: string | null | undefined; + select(optionId: string): void | Promise; + }; + modes?: { + options: readonly AgentMode[]; + selectedId: string | null | undefined; + select(modeId: string): void | Promise; + }; + features: { + list: readonly AgentFeature[] | undefined; + set?: (featureId: string, value: unknown) => void | Promise; + }; +} + +function ignoreAgentFeatureChange(): void { + return; +} + +function resolveDefaultModeId( + provider: string | null | undefined, + providerDefinitions: AgentProviderDefinition[], +): string | null { + return ( + providerDefinitions.find((definition) => definition.id === provider)?.defaultModeId ?? null + ); +} + +function buildModeContributionSource( + options: readonly AgentMode[] | undefined, + selectedId: string | null | undefined, + select: ((modeId: string) => void | Promise) | undefined, + defaultModeId: string | null, +): Pick { + if (!options || !select) return {}; + return { + modes: { + options, + selectedId: selectedId ?? null, + defaultModeId, + select, + }, + }; +} + +export function useAgentControlCommandCenterActions(input: { + sourceId: string; + enabled: boolean; + controls: AgentControlCommandCenterSource; +}): void { + const { t } = useTranslation(); + const { controls } = input; + const { features, models, modes, thinking } = controls; + const actions = useMemo( + () => + buildAgentControlContributions({ + serverId: controls.serverId, + ownerKey: controls.ownerKey, + provider: controls.provider ?? null, + labels: buildAgentControlContributionLabels(t), + icons: { + provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)), + thinking: getCommandCenterIcon(ThinkingIcon), + planMode: getCommandCenterIcon(PlanModeIcon), + mode: (modeId) => + getCommandCenterIcon( + getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions), + ), + feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)), + }, + models: { + providers: models.providers, + selectedProvider: models.selectedProvider ?? null, + selectedModelId: models.selectedModelId ?? null, + select: models.select, + }, + thinking: { + options: thinking.options ?? [], + selectedId: thinking.selectedId ?? null, + select: thinking.select, + }, + ...buildModeContributionSource( + modes?.options, + modes?.selectedId, + modes?.select, + resolveDefaultModeId(controls.provider, controls.providerDefinitions), + ), + features: { + list: features.set ? (features.list ?? []) : [], + set: features.set ?? ignoreAgentFeatureChange, + }, + }), + [ + controls.ownerKey, + controls.provider, + controls.providerDefinitions, + controls.serverId, + features.list, + features.set, + models.providers, + models.select, + models.selectedModelId, + models.selectedProvider, + modes?.options, + modes?.select, + modes?.selectedId, + thinking.options, + thinking.select, + thinking.selectedId, + t, + ], + ); + + useCommandCenterActions({ + sourceId: input.sourceId, + enabled: input.enabled, + actions, + }); +} diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index f66cbf254a..51b94eeca8 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -401,6 +401,15 @@ interface ResultRowProps { const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRowProps) { const press = useCallback(() => onSelect(result), [onSelect, result]); + const choice = + result.kind === "contribution" && result.contribution.presentation.kind === "choice" + ? result.contribution.presentation + : null; + const accessibilityLabel = choice?.path.join(" › "); + const accessibilityState = useMemo( + () => (isNative && choice ? { selected: choice.selected } : undefined), + [choice], + ); const style = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.row, @@ -415,7 +424,15 @@ const ResultRow = memo(function ResultRow({ result, active, onSelect }: ResultRo [active, result], ); return ( - + ); @@ -497,7 +514,7 @@ function ResultContent({ result }: { result: CommandCenterResult }) { ); } return ( - + {Icon ? ( diff --git a/packages/app/src/command-center/icon.tsx b/packages/app/src/command-center/icon.tsx new file mode 100644 index 0000000000..569e3575f9 --- /dev/null +++ b/packages/app/src/command-center/icon.tsx @@ -0,0 +1,19 @@ +import { withUnistyles } from "react-native-unistyles"; +import type { AgentControlIcon } from "@/agent-controls/icons"; +import type { CommandCenterIcon, CommandCenterIconProps } from "./contributions"; + +const commandCenterIcons = new Map(); + +export function getCommandCenterIcon(Component: AgentControlIcon): CommandCenterIcon { + const cached = commandCenterIcons.get(Component); + if (cached) return cached; + + const ThemedIcon = withUnistyles(Component, (theme) => ({ + color: theme.colors.foregroundMuted, + })); + function CommandCenterIconAdapter({ size }: CommandCenterIconProps) { + return ; + } + commandCenterIcons.set(Component, CommandCenterIconAdapter); + return CommandCenterIconAdapter; +} diff --git a/packages/app/src/command-center/model-contributions.test.tsx b/packages/app/src/command-center/model-contributions.test.tsx deleted file mode 100644 index 6e38b33e14..0000000000 --- a/packages/app/src/command-center/model-contributions.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; -import { buildModelChoiceContributions } from "./model-contributions"; - -function TestIcon() { - return null; -} - -function provider(input: { - id: string; - label: string; - state: "models" | "error"; -}): ProviderSelectorProvider { - if (input.state === "error") { - return { - id: input.id, - label: input.label, - modelSelection: { kind: "error", message: "Unavailable" }, - }; - } - return { - id: input.id, - label: input.label, - modelSelection: { - kind: "models", - rows: [ - { - favoriteKey: `${input.id}:model`, - provider: input.id, - providerLabel: input.label, - modelId: "model", - modelLabel: `${input.label} model`, - description: undefined, - }, - ], - }, - }; -} - -describe("Command Center model choices", () => { - it("publishes selectable providers and executes the draft owner's command", () => { - const selections: string[] = []; - const choices = buildModelChoiceContributions({ - serverId: "host", - providers: [ - provider({ id: "claude", label: "Claude", state: "models" }), - provider({ id: "codex", label: "Codex", state: "models" }), - ], - selectedProvider: "codex", - selectedModelId: "model", - groupLabel: "Model", - searchKeywords: "model switch", - getIcon: () => TestIcon, - select: (selectedProvider, modelId) => selections.push(`${selectedProvider}:${modelId}`), - }); - - expect( - choices.map((choice) => ({ - id: choice.id, - selected: choice.presentation.kind === "choice" ? choice.presentation.selected : false, - })), - ).toEqual([ - { id: "host:claude:model", selected: false }, - { id: "host:codex:model", selected: true }, - ]); - choices[0].run(); - choices[1].run(); - expect(selections).toEqual(["claude:model"]); - }); - - it("does not publish unavailable providers or no-op default rows", () => { - const choices = buildModelChoiceContributions({ - serverId: "host", - providers: [ - provider({ id: "unavailable", label: "Unavailable", state: "error" }), - { - id: "empty", - label: "Empty", - modelSelection: { - kind: "models", - rows: [ - { - favoriteKey: "empty:", - provider: "empty", - providerLabel: "Empty", - modelId: "", - modelLabel: "Default", - description: undefined, - }, - ], - }, - }, - ], - selectedProvider: null, - selectedModelId: null, - groupLabel: "Model", - searchKeywords: "model switch", - getIcon: () => TestIcon, - select: () => undefined, - }); - - expect(choices).toEqual([]); - }); -}); diff --git a/packages/app/src/command-center/model-contributions.ts b/packages/app/src/command-center/model-contributions.ts deleted file mode 100644 index 182a528c94..0000000000 --- a/packages/app/src/command-center/model-contributions.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; -import type { CommandCenterContribution, CommandCenterIcon } from "./contributions"; - -interface ModelChoiceInput { - serverId: string; - providers: readonly ProviderSelectorProvider[]; - selectedProvider: string | null; - selectedModelId: string | null; - groupLabel: string; - searchKeywords: string; - getIcon(provider: AgentProvider): CommandCenterIcon; - select(provider: AgentProvider, modelId: string): void; -} - -export function buildModelChoiceContributions( - input: ModelChoiceInput, -): CommandCenterContribution[] { - const contributions: CommandCenterContribution[] = []; - let rank = 0; - for (const provider of input.providers) { - if (provider.modelSelection.kind !== "models") continue; - const agentProvider = provider.id; - const icon = input.getIcon(agentProvider); - for (const model of provider.modelSelection.rows) { - if (!model.modelId) continue; - const modelId = model.modelId; - const selected = input.selectedProvider === provider.id && input.selectedModelId === modelId; - contributions.push({ - id: `${input.serverId}:${provider.id}:${modelId}`, - group: "models", - groupRank: 1, - rank, - keywords: [modelId, input.searchKeywords], - visibility: "query", - run: () => { - if (!selected) input.select(agentProvider, modelId); - }, - presentation: { - kind: "choice", - path: [input.groupLabel, provider.label, model.modelLabel], - icon, - selected, - testId: `command-center-model-${input.serverId}:${provider.id}:${modelId}`, - }, - }); - rank += 1; - } - } - return contributions; -} diff --git a/packages/app/src/command-center/provider-icon.tsx b/packages/app/src/command-center/provider-icon.tsx deleted file mode 100644 index 245d5de272..0000000000 --- a/packages/app/src/command-center/provider-icon.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { withUnistyles } from "react-native-unistyles"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -import { getProviderIcon } from "@/components/provider-icons"; -import type { CommandCenterIcon, CommandCenterIconProps } from "./contributions"; - -const commandCenterProviderIcons = new Map(); - -export function getCommandCenterProviderIcon(provider: AgentProvider): CommandCenterIcon { - const cached = commandCenterProviderIcons.get(provider); - if (cached) return cached; - - const ProviderIcon = withUnistyles(getProviderIcon(provider), (theme) => ({ - color: theme.colors.foregroundMuted, - })); - function CommandCenterProviderIcon({ size }: CommandCenterIconProps) { - return ; - } - commandCenterProviderIcons.set(provider, CommandCenterProviderIcon); - return CommandCenterProviderIcon; -} diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index febfbf6cae..7931b95481 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -33,7 +33,7 @@ import { type SelectFieldOption, type SelectFieldRenderOptionInput, } from "@/components/ui/select-field"; -import { formatThinkingOptionLabel } from "@/composer/agent-controls/utils"; +import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { mergeProviderPreferences, useFormPreferences, diff --git a/packages/app/src/composer/agent-controls/control.tsx b/packages/app/src/composer/agent-controls/control.tsx index f8d23390f9..2077c86ab0 100644 --- a/packages/app/src/composer/agent-controls/control.tsx +++ b/packages/app/src/composer/agent-controls/control.tsx @@ -1,16 +1,10 @@ -import { forwardRef, useCallback, type ComponentType } from "react"; +import { forwardRef, useCallback } from "react"; import { Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph"; - -export interface AgentControlIconProps { - size?: number; - color?: string; -} - -export type AgentControlIcon = ComponentType; +import type { AgentControlIcon } from "@/agent-controls/icons"; interface AgentControlTriggerProps { icon: AgentControlIcon; diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index dc9b0bee01..6e727fee02 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -22,7 +22,9 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; -import { Brain, ListTodo, Settings2, ShieldCheck, Zap } from "lucide-react-native"; +import { Settings2 } from "lucide-react-native"; +import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons"; +import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { @@ -58,16 +60,13 @@ import { getFeatureHighlightColor, getFeatureTooltip, getAgentControlHintKey, - formatThinkingOptionLabel, resolveAgentModelSelection, } from "@/composer/agent-controls/utils"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; -import { useCommandCenterActions } from "@/command-center/provider"; -import { buildModelChoiceContributions } from "@/command-center/model-contributions"; -import { getCommandCenterProviderIcon } from "@/command-center/provider-icon"; +import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; import { isNative } from "@/constants/platform"; import { resolveComposerControlDensity, @@ -88,6 +87,8 @@ interface AgentControlOption { type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; +const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = []; + interface ControlledAgentControlsProps { provider: string; providerOptions?: AgentControlOption[]; @@ -164,14 +165,17 @@ function findOptionLabel( return selected?.label ?? fallback; } -const FEATURE_ICONS: Record = { - "list-todo": ListTodo, - "shield-check": ShieldCheck, - zap: Zap, -}; +function toCommandCenterModes(modeControl: AgentModeControlValue | null) { + if (!modeControl) return undefined; + return { + options: modeControl.modeOptions, + selectedId: modeControl.selectedModeId, + select: modeControl.onSelectMode, + }; +} -function getFeatureIcon(icon?: string) { - return (icon && FEATURE_ICONS[icon]) || Settings2; +function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) { + return modeControl?.providerDefinitions ?? EMPTY_AGENT_PROVIDER_DEFINITIONS; } function getFeatureIconColor( @@ -923,7 +927,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { @@ -1246,7 +1250,7 @@ function DesktopFeatureItem({ } if (feature.type === "select") { - const FeatureIcon = getFeatureIcon(feature.icon); + const FeatureIcon = getAgentFeatureIcon(feature.icon); const selectedOption = feature.options.find((o) => o.id === feature.value); return ( <> @@ -1334,7 +1338,7 @@ function SheetFeatureItem({ ); if (feature.type === "toggle") { - const FeatureIcon = getFeatureIcon(feature.icon); + const FeatureIcon = getAgentFeatureIcon(feature.icon); return ( o.id === feature.value); return ( <> @@ -1402,7 +1406,7 @@ function ThinkingComboboxOption({ onPress: () => void; iconColor: string; }) { - const leadingSlot = useMemo(() => , [iconColor]); + const leadingSlot = useMemo(() => , [iconColor]); return ( selectAgentControlsSlice(state, serverId, agentId)), @@ -1429,6 +1432,8 @@ export const AgentControls = memo(function AgentControls({ const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const toast = useToast(); const modeControl = useLiveAgentModeControl(serverId, agentId); + const commandCenterModes = toCommandCenterModes(modeControl); + const modeProviderDefinitions = getModeProviderDefinitions(modeControl); const { entries: snapshotEntries, @@ -1504,9 +1509,7 @@ export const AgentControls = memo(function AgentControls({ mergeProviderPreferences({ preferences: current, provider: agentProvider, - updates: { - model: modelId, - }, + updates: { model: modelId }, }), ); } catch (error) { @@ -1516,26 +1519,10 @@ export const AgentControls = memo(function AgentControls({ }, [agentId, agentProvider, client, toast, updatePreferences], ); - - const commandCenterModelActions = useMemo( - () => - buildModelChoiceContributions({ - serverId, - providers: agentModelSelectorProviders, - selectedProvider: agentProvider ?? null, - selectedModelId: activeModelId, - groupLabel: t("shell.commandCenter.modelGroupLabel"), - searchKeywords: t("shell.commandCenter.modelSearchKeywords"), - getIcon: getCommandCenterProviderIcon, - select: (_provider, modelId) => handleSelectModel(modelId), - }), - [activeModelId, agentModelSelectorProviders, agentProvider, handleSelectModel, serverId, t], + const handleSelectCommandCenterModel = useCallback( + (_provider: AgentProvider, modelId: string) => handleSelectModel(modelId), + [handleSelectModel], ); - useCommandCenterActions({ - sourceId: `agent:${serverId}:${agentId}`, - enabled: isPaneFocused && Boolean(client), - actions: commandCenterModelActions, - }); const handleToggleFavoriteModel = useCallback( (provider: string, modelId: string) => { @@ -1606,6 +1593,33 @@ export const AgentControls = memo(function AgentControls({ [agentId, agentProvider, client, toast, updatePreferences], ); + useAgentControlCommandCenterActions({ + sourceId: `agent:${serverId}:${agentId}`, + enabled: isPaneFocused && Boolean(client), + controls: { + serverId, + ownerKey: agentId, + provider: agentProvider, + providerDefinitions: modeProviderDefinitions, + models: { + providers: agentModelSelectorProviders, + selectedProvider: agentProvider, + selectedModelId: activeModelId, + select: handleSelectCommandCenterModel, + }, + thinking: { + options: modelSelection.thinkingOptions, + selectedId: modelSelection.selectedThinkingId, + select: handleSelectThinkingOption, + }, + modes: commandCenterModes, + features: { + list: agent?.features, + set: handleSetFeature, + }, + }, + }); + const handleModelSelectorOpen = useCallback(() => { refetchSnapshotIfStale(agentProvider); }, [agentProvider, refetchSnapshotIfStale]); diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index c4c049c777..4534dd1500 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -1,38 +1,15 @@ -import { - useCallback, - useMemo, - useRef, - useState, - type ComponentType, - type ReactElement, -} from "react"; +import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { - Bot, - Shield, - ShieldAlert, - ShieldCheck, - ShieldEllipsis, - ShieldOff, - ShieldPlus, - ShieldQuestionMark, -} from "lucide-react-native"; import { type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; -import { useSessionStore } from "@/stores/session-store"; -import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; -import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; -import { resolveProviderDefinition } from "@/utils/provider-definitions"; -import { useToast } from "@/contexts/toast-context"; -import { toErrorMessage } from "@/utils/error-messages"; -import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; -import { formatAgentModeLabel, getAgentControlHintKey } from "@/composer/agent-controls/utils"; +import { formatAgentModeLabel } from "@/agent-controls/labels"; +import { getAgentControlHintKey } from "@/composer/agent-controls/utils"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; @@ -40,25 +17,16 @@ import { resolveNextAgentModeId } from "@/composer/agent-controls/mode"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; +import { useSessionStore } from "@/stores/session-store"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; +import { resolveProviderDefinition } from "@/utils/provider-definitions"; +import { useToast } from "@/contexts/toast-context"; +import { toErrorMessage } from "@/utils/error-messages"; +import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; import type { AgentMode } from "@getpaseo/protocol/agent-types"; -import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; - -interface ModeIconProps { - size?: number; - color?: string; -} - -const MODE_ICONS: Record> = { - Bot, - Shield, - ShieldCheck, - ShieldAlert, - ShieldEllipsis, - ShieldOff, - ShieldPlus, - ShieldQuestionMark, -}; - +import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; +import { getAgentModeIcon, getAgentModeOptionIcon } from "@/agent-controls/icons"; interface ModeComboboxOptionProps { option: ComboboxOption; selected: boolean; @@ -78,8 +46,7 @@ function ModeComboboxOption({ providerDefinitions, iconColor, }: ModeComboboxOptionProps) { - const visuals = getModeVisuals(provider, option.id, providerDefinitions); - const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : undefined; + const IconComponent = getAgentModeOptionIcon(provider, option.id, providerDefinitions); const leadingSlot = useMemo( () => (IconComponent ? : null), [IconComponent, iconColor], @@ -134,10 +101,7 @@ export function AgentModeControl({ return modeOptions.find((m) => m.id === selectedModeId) ?? modeOptions[0]; }, [modeOptions, selectedModeId]); - const visuals = selectedMode - ? getModeVisuals(provider, selectedMode.id, providerDefinitions) - : undefined; - const Icon = visuals?.icon ? (MODE_ICONS[visuals.icon] ?? Bot) : Bot; + const Icon = getAgentModeIcon(provider, selectedMode?.id ?? "", providerDefinitions); const iconColor = theme.colors.foregroundMuted; const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : ""; @@ -316,9 +280,7 @@ export function useLiveAgentModeControl( mergeProviderPreferences({ preferences: current, provider: slice.provider, - updates: { - mode: modeId || undefined, - }, + updates: { mode: modeId || undefined }, }), ).catch((error) => { console.warn("[AgentModeControl] persist mode preference failed", error); diff --git a/packages/app/src/composer/agent-controls/utils.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts index 1ab8e0b227..cd6934d127 100644 --- a/packages/app/src/composer/agent-controls/utils.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from "vitest"; import { - formatAgentModeLabel, getFeatureHighlightColor, getFeatureTooltip, getAgentControlHintKey, - formatThinkingOptionLabel, normalizeModelId, resolveAgentModelSelection, } from "./utils"; @@ -54,36 +52,6 @@ describe("normalizeModelId", () => { }); }); -describe("formatAgentModeLabel", () => { - it("sentence-cases provider mode labels", () => { - expect(formatAgentModeLabel({ id: "plan", label: "Plan" })).toBe("Plan"); - expect(formatAgentModeLabel({ id: "full-access", label: "Full Access" })).toBe("Full access"); - expect(formatAgentModeLabel({ id: "auto-review", label: "Auto-review" })).toBe("Auto-review"); - expect(formatAgentModeLabel({ id: "read_only", label: "read_only" })).toBe("Read only"); - expect(formatAgentModeLabel({ id: "acceptEdits", label: "acceptEdits" })).toBe("Accept edits"); - }); - - it("splits compact mode ids when no provider label is available", () => { - expect(formatAgentModeLabel({ id: "auto-review" })).toBe("Auto review"); - }); -}); - -describe("formatThinkingOptionLabel", () => { - it("formats compact thinking option labels for display", () => { - expect(formatThinkingOptionLabel({ id: "none", label: "none" })).toBe("None"); - expect(formatThinkingOptionLabel({ id: "low", label: "low" })).toBe("Low"); - expect(formatThinkingOptionLabel({ id: "medium", label: "medium" })).toBe("Medium"); - expect(formatThinkingOptionLabel({ id: "high", label: "high" })).toBe("High"); - expect(formatThinkingOptionLabel({ id: "xhigh", label: "xhigh" })).toBe("Extra high"); - }); - - it("sentence-cases split provider labels", () => { - expect(formatThinkingOptionLabel({ id: "extra_high", label: "extra_high" })).toBe("Extra high"); - expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard"); - expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high"); - }); -}); - describe("resolveAgentModelSelection", () => { it("prefers runtime model over configured model", () => { const selection = resolveAgentModelSelection({ diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index e2229a0e98..7313e160b9 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -1,5 +1,7 @@ import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; import { i18n } from "@/i18n/i18next"; +import { formatThinkingOptionLabel } from "@/agent-controls/labels"; +import { FAST_MODE_FEATURE_ID, PLAN_MODE_FEATURE_ID } from "@/agent-controls/policy"; export type ExplainedAgentControl = "mode" | "model" | "thinking"; export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow"; @@ -35,61 +37,17 @@ export function getFeatureTooltip(feature: Pick - buildModelChoiceContributions({ - serverId, - providers: composerState.modelSelectorProviders, - selectedProvider: composerState.selectedProvider, - selectedModelId: composerState.effectiveModelId || null, - groupLabel: t("shell.commandCenter.modelGroupLabel"), - searchKeywords: t("shell.commandCenter.modelSearchKeywords"), - getIcon: getCommandCenterProviderIcon, - select: composerState.setProviderAndModelFromUser, - }), - [ - composerState.effectiveModelId, - composerState.modelSelectorProviders, - composerState.selectedProvider, - composerState.setProviderAndModelFromUser, - serverId, - t, - ], - ); + const draftProvider = composerState.selectedProvider; + const draftProviderDefinitions = composerState.providerDefinitions; + const draftThinkingOptions = composerState.availableThinkingOptions; + const draftSelectedThinkingId = composerState.selectedThinkingOptionId; + const draftSetThinkingOption = composerState.setThinkingOptionFromUser; + const draftModeOptions = composerState.modeOptions; + const draftSelectedMode = composerState.selectedMode; + const draftSetMode = composerState.setModeFromUser; + const draftFeatures = composerState.agentControls.features; + const draftOnSetFeature = composerState.agentControls.onSetFeature; + const clearDraftInput = draftInput.clear; const setDraftText = draftInput.setText; const setDraftAttachments = draftInput.setAttachments; @@ -541,12 +529,36 @@ export function WorkspaceDraftAgentTab({ onCreated(result); }, }); - useCommandCenterActions({ + useAgentControlCommandCenterActions({ sourceId: `draft:${serverId}:${tabId}`, enabled: isPaneFocused && !isSubmitting, - actions: draftModelActions, + controls: { + serverId, + ownerKey: tabId, + provider: draftProvider, + providerDefinitions: draftProviderDefinitions, + models: { + providers: composerState.modelSelectorProviders, + selectedProvider: draftProvider, + selectedModelId: composerState.effectiveModelId, + select: composerState.setProviderAndModelFromUser, + }, + thinking: { + options: draftThinkingOptions, + selectedId: draftSelectedThinkingId, + select: draftSetThinkingOption, + }, + modes: { + options: draftModeOptions, + selectedId: draftSelectedMode, + select: draftSetMode, + }, + features: { + list: draftFeatures, + set: draftOnSetFeature, + }, + }, }); - const isReadyForPendingAutoSubmit = Boolean( pendingAutoSubmit && draftInput.isHydrated && diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 124c6643ef..fcc16aa67c 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -188,6 +188,12 @@ describe("translation resources", () => { expect(en.shell.commandCenter.modelSearchKeywords).toBe( "switch model change model set model select model", ); + expect(en.shell.commandCenter.thinkingGroupLabel).toBe("Thinking"); + expect(en.shell.commandCenter.modeGroupLabel).toBe("Mode"); + expect(en.shell.commandCenter.planModeGroupLabel).toBe("Plan mode"); + expect(en.shell.commandCenter.fastModeGroupLabel).toBe("Fast"); + expect(en.shell.commandCenter.settingOn).toBe("On"); + expect(en.shell.commandCenter.settingOff).toBe("Off"); }); it("includes composer and agent workflow keys for the Batch 2 migration", () => { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c24cb3ddb7..4a9ffe67fc 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -64,6 +64,16 @@ export const ar: TranslationResources = { home: "بيت", modelGroupLabel: "النموذج", modelSearchKeywords: "تبديل النموذج تغيير النموذج تعيين النموذج اختيار النموذج", + thinkingGroupLabel: "التفكير", + thinkingSearchKeywords: "الاستدلال الجهد التفكير", + modeGroupLabel: "الوضع", + modeSearchKeywords: "الوصول الإذن الموافقة الوضع", + planModeGroupLabel: "وضع التخطيط", + planModeSearchKeywords: "التخطيط خطة وضع التخطيط", + fastModeGroupLabel: "سريع", + fastModeSearchKeywords: "سريع سرعة زمن انتقال منخفض", + settingOn: "تشغيل", + settingOff: "إيقاف", }, }, composer: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index dd0191c7ad..a5aef1b400 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -62,6 +62,16 @@ export const en = { home: "Home", modelGroupLabel: "Model", modelSearchKeywords: "switch model change model set model select model", + thinkingGroupLabel: "Thinking", + thinkingSearchKeywords: "reasoning effort thinking think", + modeGroupLabel: "Mode", + modeSearchKeywords: "access permission approval mode", + planModeGroupLabel: "Plan mode", + planModeSearchKeywords: "plan planning plan mode", + fastModeGroupLabel: "Fast", + fastModeSearchKeywords: "fast speed low latency", + settingOn: "On", + settingOff: "Off", }, }, composer: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ebc95b0b7c..eee5987893 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -64,6 +64,16 @@ export const es: TranslationResources = { home: "Hogar", modelGroupLabel: "Modelo", modelSearchKeywords: "cambiar modelo modificar modelo establecer modelo seleccionar modelo", + thinkingGroupLabel: "Razonamiento", + thinkingSearchKeywords: "razonamiento esfuerzo pensamiento", + modeGroupLabel: "Modo", + modeSearchKeywords: "acceso permiso aprobación modo", + planModeGroupLabel: "Modo plan", + planModeSearchKeywords: "plan planificación modo plan", + fastModeGroupLabel: "Rápido", + fastModeSearchKeywords: "rápido velocidad baja latencia", + settingOn: "Activado", + settingOff: "Desactivado", }, }, composer: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index ab59cee828..a015a9156d 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -66,6 +66,16 @@ export const fr: TranslationResources = { modelGroupLabel: "Modèle", modelSearchKeywords: "changer de modèle modifier le modèle définir le modèle sélectionner le modèle", + thinkingGroupLabel: "Raisonnement", + thinkingSearchKeywords: "raisonnement effort réflexion", + modeGroupLabel: "Mode", + modeSearchKeywords: "accès permission approbation mode", + planModeGroupLabel: "Mode plan", + planModeSearchKeywords: "plan planification mode plan", + fastModeGroupLabel: "Rapide", + fastModeSearchKeywords: "rapide vitesse faible latence", + settingOn: "Activé", + settingOff: "Désactivé", }, }, composer: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 0cd8669133..54b19efc3f 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -64,6 +64,16 @@ export const ja: TranslationResources = { home: "ホーム", modelGroupLabel: "モデル", modelSearchKeywords: "モデルを切り替え モデルを変更 モデルを設定 モデルを選択", + thinkingGroupLabel: "思考", + thinkingSearchKeywords: "推論 思考レベル 考える", + modeGroupLabel: "モード", + modeSearchKeywords: "アクセス 権限 承認 モード", + planModeGroupLabel: "プランモード", + planModeSearchKeywords: "プラン 計画 プランモード", + fastModeGroupLabel: "高速", + fastModeSearchKeywords: "高速 速度 低遅延", + settingOn: "オン", + settingOff: "オフ", }, }, composer: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index a826e149c9..b79f7b9777 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -64,6 +64,16 @@ export const ptBR: TranslationResources = { home: "Início", modelGroupLabel: "Modelo", modelSearchKeywords: "trocar modelo mudar modelo definir modelo selecionar modelo", + thinkingGroupLabel: "Raciocínio", + thinkingSearchKeywords: "raciocínio esforço pensamento", + modeGroupLabel: "Modo", + modeSearchKeywords: "acesso permissão aprovação modo", + planModeGroupLabel: "Modo plano", + planModeSearchKeywords: "plano planejamento modo plano", + fastModeGroupLabel: "Rápido", + fastModeSearchKeywords: "rápido velocidade baixa latência", + settingOn: "Ligado", + settingOff: "Desligado", }, }, composer: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 2e2fbc30af..5557989c08 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -64,6 +64,16 @@ export const ru: TranslationResources = { home: "Дом", modelGroupLabel: "Модель", modelSearchKeywords: "сменить модель изменить модель выбрать модель установить модель", + thinkingGroupLabel: "Мышление", + thinkingSearchKeywords: "рассуждение усилие мышление", + modeGroupLabel: "Режим", + modeSearchKeywords: "доступ разрешение одобрение режим", + planModeGroupLabel: "Режим плана", + planModeSearchKeywords: "план планирование режим плана", + fastModeGroupLabel: "Быстрый", + fastModeSearchKeywords: "быстрый скорость низкая задержка", + settingOn: "Вкл", + settingOff: "Выкл", }, }, composer: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 0fb6af49c2..d40c499c6b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -64,6 +64,16 @@ export const zhCN: TranslationResources = { home: "首页", modelGroupLabel: "模型", modelSearchKeywords: "切换模型 更改模型 设置模型 选择模型", + thinkingGroupLabel: "思考", + thinkingSearchKeywords: "推理 努力 思考", + modeGroupLabel: "模式", + modeSearchKeywords: "访问 权限 批准 模式", + planModeGroupLabel: "计划模式", + planModeSearchKeywords: "计划 规划 计划模式", + fastModeGroupLabel: "快速", + fastModeSearchKeywords: "快速 速度 低延迟", + settingOn: "开", + settingOff: "关", }, }, composer: { diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts index d81c5c92a9..4835453912 100644 --- a/packages/app/src/schedules/schedule-form-model.ts +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -6,7 +6,7 @@ import type { } from "@getpaseo/protocol/agent-types"; import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types"; import type { FormPreferences } from "@/create-agent-preferences/preferences"; -import { formatThinkingOptionLabel } from "@/composer/agent-controls/utils"; +import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { buildSelectableProviderSelectorProviders, type ProviderSelectorProvider, From de44c8cd666a8eb729e307597918ac0fb6b3e1be Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 17:50:35 +0200 Subject: [PATCH 34/46] Show New Workspace isolation controls immediately (#2702) * fix(workspaces): show isolation without checkout delay * fix(workspaces): trust hydrated project capability * fix(workspaces): resolve branch before creation * fix(workspaces): honor invalidated checkout status * fix(workspaces): preserve isolation while projects load * fix(workspaces): keep provisional project routes usable Route-derived projects do not know their worktree capability. Represent that uncertainty at the placement boundary so remembered worktree intent survives hydration without disabling the New Workspace screen indefinitely. --- packages/app/e2e/helpers/new-workspace.ts | 28 +++++- packages/app/e2e/new-workspace-entry.spec.ts | 16 ++- .../sidebar/sidebar-projection.test.ts | 2 +- .../app/src/git/checkout-status-cache.test.ts | 35 +++++++ packages/app/src/git/checkout-status-cache.ts | 18 ++++ .../sidebar-workspaces-view-model.test.ts | 44 +++++++-- .../hooks/sidebar-workspaces-view-model.ts | 4 +- .../app/src/projects/host-project-model.ts | 17 +++- .../app/src/projects/host-projects.test.ts | 37 ++++++- packages/app/src/projects/host-projects.ts | 1 + .../app/src/projects/workspace-structure.ts | 6 +- .../new-workspace-initial-context.test.ts | 9 +- .../screens/new-workspace-picker-item.test.ts | 35 ++++++- .../src/screens/new-workspace-picker-item.ts | 14 +++ .../app/src/screens/new-workspace-screen.tsx | 99 +++++++++---------- .../screens/new-workspace/project-picker.ts | 6 +- .../new-workspace/project-selection.test.ts | 16 +-- .../utils/sidebar-project-row-model.test.ts | 42 ++++++-- .../src/utils/sidebar-project-row-model.ts | 5 +- .../app/src/utils/sidebar-shortcuts.test.ts | 4 +- 20 files changed, 333 insertions(+), 105 deletions(-) diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index 7a021fb263..55caebd6b1 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -190,6 +190,29 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise }); } +export async function openMissingProjectNewWorkspaceComposer( + page: Page, + input: { serverId: string; projectId: string; sourceDirectory: string }, +): Promise { + const query = new URLSearchParams({ + serverId: input.serverId, + projectId: input.projectId, + dir: input.sourceDirectory, + name: "Missing project", + }); + await page.goto(`/new?${query.toString()}`); + await expect(page).toHaveURL(/\/new\?.*projectId=/u, { timeout: 30_000 }); +} + +export async function expectNewWorkspaceControlsEnabled(page: Page): Promise { + await expect(page.getByRole("button", { name: "Workspace project" })).toBeEnabled({ + timeout: 30_000, + }); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({ + timeout: 30_000, + }); +} + export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise { await page.keyboard.press("Control+P"); @@ -286,9 +309,8 @@ export async function selectWorkspaceIsolation( await expect(trigger).toBeVisible({ timeout: 30_000 }); await trigger.click(); - // "New worktree" is only listed once the checkout status query confirms the - // selected project is a git repo, so wait for the option to appear before - // clicking it. + // Isolation options are derived from project capability. Wait for the option + // so this helper also covers route-to-project reconciliation. const option = page.getByTestId(`workspace-create-isolation-${isolation}`); await expect(option).toBeVisible({ timeout: 30_000 }); await option.click(); diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index 920b034158..bc4c64229e 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -2,8 +2,10 @@ import { expect, test } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; import { connectNewWorkspaceDaemonClient, + expectNewWorkspaceControlsEnabled, expectNewWorkspaceProjectSelected, openGlobalNewWorkspaceComposer, + openMissingProjectNewWorkspaceComposer, openNewWorkspaceComposer, openNewWorkspaceProjectPickerWithShortcut, } from "./helpers/new-workspace"; @@ -13,7 +15,7 @@ import { seedSavedSettingsHosts } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; -import { waitForSidebarHydration } from "./helpers/workspace-ui"; +import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui"; // Model B entry points into the New Workspace screen. The surviving entries are // the global button (universal) and each project's per-row New workspace icon @@ -108,6 +110,18 @@ test.describe("New workspace entry points", () => { } }); + test("a stale project route keeps the New Workspace controls enabled", async ({ page }) => { + await gotoAppShell(page); + await waitForNoProjectsInSidebar(page); + await openMissingProjectNewWorkspaceComposer(page, { + serverId: getServerId(), + projectId: "missing-project", + sourceDirectory: "/tmp/missing-project", + }); + + await expectNewWorkspaceControlsEnabled(page); + }); + test("Ctrl+P opens the project picker with search focused", async ({ page }) => { const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" }); diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index d456efa3b3..9338d55a47 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -45,7 +45,7 @@ function makeProject(workspaces: SidebarWorkspacePlacement[]): SidebarProjectEnt serverId: "srv", projectId: "project", iconWorkingDir: "/repo", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaces, diff --git a/packages/app/src/git/checkout-status-cache.test.ts b/packages/app/src/git/checkout-status-cache.test.ts index 4bd435e4ef..df8952749c 100644 --- a/packages/app/src/git/checkout-status-cache.test.ts +++ b/packages/app/src/git/checkout-status-cache.test.ts @@ -14,6 +14,7 @@ import { import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store"; import { applyCheckoutStatusUpdateFromEvent, + ensureCheckoutStatus, type CheckoutPrStatusPayload, type CheckoutStatusPayload, fetchCheckoutStatus, @@ -122,6 +123,40 @@ describe("fetchCheckoutStatus", () => { }); }); +describe("ensureCheckoutStatus", () => { + it("awaits the canonical checkout-status query and reuses its cached result", async () => { + const queryClient = createQueryClient(); + const fetched = checkoutStatus({ currentBranch: "feature/current" }); + const client = { getCheckoutStatus: vi.fn(async () => fetched) }; + + const first = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); + const second = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); + + expect(first).toEqual(fetched); + expect(second).toEqual(fetched); + expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); + }); + + it("awaits a refetch when the canonical cached status was invalidated", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + checkoutStatusQueryKey(serverId, cwd), + checkoutStatus({ currentBranch: "feature/stale" }), + ); + await queryClient.invalidateQueries({ + queryKey: checkoutStatusQueryKey(serverId, cwd), + refetchType: "none", + }); + const fetched = checkoutStatus({ currentBranch: "feature/current" }); + const client = { getCheckoutStatus: vi.fn(async () => fetched) }; + + const result = await ensureCheckoutStatus({ queryClient, client, serverId, cwd }); + + expect(result.currentBranch).toBe("feature/current"); + expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd); + }); +}); + describe("applyCheckoutStatusUpdateFromEvent", () => { it("writes the checkout status to the cache using the cwd from the payload", () => { const queryClient = createQueryClient(); diff --git a/packages/app/src/git/checkout-status-cache.ts b/packages/app/src/git/checkout-status-cache.ts index 79ba03a415..1f76dc67b1 100644 --- a/packages/app/src/git/checkout-status-cache.ts +++ b/packages/app/src/git/checkout-status-cache.ts @@ -35,6 +35,24 @@ export async function fetchCheckoutStatus({ return payload; } +export async function ensureCheckoutStatus({ + queryClient, + client, + serverId, + cwd, +}: { + queryClient: QueryClient; + client: CheckoutStatusClient; + serverId: string; + cwd: string; +}): Promise { + return await queryClient.fetchQuery({ + queryKey: checkoutStatusQueryKey(serverId, cwd), + queryFn: () => fetchCheckoutStatus({ client, serverId, cwd }), + staleTime: Infinity, + }); +} + export function applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index 5f4e666d02..d108f9d862 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -93,7 +93,7 @@ function project(input: { { serverId: "srv", iconWorkingDir: input.iconWorkingDir ?? input.projectKey, - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], (host) => Object.assign({}, host, { projectId: host.projectId ?? input.projectKey }), @@ -251,7 +251,7 @@ describe("buildSidebarProjectsFromStructure", () => { { serverId: "relay:paseo-host", iconWorkingDir: "/repo/project-1", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaceKeys: ["relay:paseo-host:ws-main"], @@ -276,8 +276,16 @@ describe("shared sidebar workspace model", () => { projectName: "getpaseo/paseo", iconWorkingDir: "/repo/getpaseo/paseo", hosts: [ - { serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true }, - { serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true }, + { + serverId: "host-a", + iconWorkingDir: "/repo/getpaseo/paseo", + worktreeSupport: "supported" as const, + }, + { + serverId: "host-b", + iconWorkingDir: "/repo/getpaseo/paseo", + worktreeSupport: "supported" as const, + }, ], workspaceKeys: ["host-a:main", "host-b:feature"], }), @@ -334,13 +342,13 @@ describe("shared sidebar workspace model", () => { serverId: "host-a", projectId: "getpaseo/paseo", iconWorkingDir: "/repo/getpaseo/paseo", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, { serverId: "host-b", projectId: "getpaseo/paseo", iconWorkingDir: "/repo/getpaseo/paseo", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaces: [ @@ -470,14 +478,22 @@ describe("shouldShowSidebarHostLabels", () => { project({ projectKey: "project-a", hosts: [ - { serverId: "host-a", iconWorkingDir: "/repo/project-a", canCreateWorktree: true }, + { + serverId: "host-a", + iconWorkingDir: "/repo/project-a", + worktreeSupport: "supported" as const, + }, ], workspaceKeys: ["host-a:ws-1"], }), project({ projectKey: "project-b", hosts: [ - { serverId: "host-b", iconWorkingDir: "/repo/project-b", canCreateWorktree: true }, + { + serverId: "host-b", + iconWorkingDir: "/repo/project-b", + worktreeSupport: "supported" as const, + }, ], workspaceKeys: ["host-b:ws-2"], }), @@ -493,8 +509,16 @@ describe("shouldShowSidebarHostLabels", () => { project({ projectKey: "getpaseo/paseo", hosts: [ - { serverId: "host-a", iconWorkingDir: "/repo/paseo", canCreateWorktree: true }, - { serverId: "host-b", iconWorkingDir: "/repo/paseo", canCreateWorktree: true }, + { + serverId: "host-a", + iconWorkingDir: "/repo/paseo", + worktreeSupport: "supported" as const, + }, + { + serverId: "host-b", + iconWorkingDir: "/repo/paseo", + worktreeSupport: "supported" as const, + }, ], workspaceKeys: ["host-a:main", "host-b:feature"], }), diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 6feb14cb84..330822799d 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -23,7 +23,7 @@ export interface SidebarWorkspacePlacement { projectName: string; projectRootPath?: string; workspaceDirectory?: string; - projectKind: WorkspaceDescriptor["projectKind"]; + projectKind: WorkspaceStructureProject["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; } @@ -52,7 +52,7 @@ export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement { export interface SidebarProjectEntry { viewKey: string; projectName: string; - projectKind: WorkspaceDescriptor["projectKind"]; + projectKind: WorkspaceStructureProject["projectKind"]; iconWorkingDir: string; hosts: WorkspaceStructureHostPlacement[]; workspaces: SidebarWorkspacePlacement[]; diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts index c0a2d8c1aa..525ea647b5 100644 --- a/packages/app/src/projects/host-project-model.ts +++ b/packages/app/src/projects/host-project-model.ts @@ -35,14 +35,14 @@ export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjec viewKey: createProjectViewKey({ kind: "placement", serverId: route.serverId, projectId }), projectKey: null, projectName: trimOptional(route.displayName) || projectId, - projectKind: "git", + projectKind: "unknown", iconWorkingDir, hosts: [ { serverId: route.serverId, projectId, iconWorkingDir, - canCreateWorktree: true, + worktreeSupport: "unknown", }, ], workspaceKeys: [], @@ -80,7 +80,7 @@ export function hostProjectFromWorkspace(input: { serverId: input.serverId, projectId: input.workspace.projectId, iconWorkingDir, - canCreateWorktree: canCreate, + worktreeSupport: canCreate ? "supported" : "unsupported", }, ], workspaceKeys: [`${input.serverId}:${input.workspace.id}`], @@ -88,7 +88,7 @@ export function hostProjectFromWorkspace(input: { } function projectCanCreateWorktree(project: HostProjectListItem): boolean { - return project.hosts.some((h) => h.canCreateWorktree); + return project.hosts.some((host) => host.worktreeSupport !== "unsupported"); } function getHostProjectPlacement( @@ -101,6 +101,13 @@ function getHostProjectPlacement( return null; } +export function getWorktreeSupportForHostProject(input: { + project: HostProjectListItem; + serverId: string; +}): WorkspaceStructureHostPlacement["worktreeSupport"] { + return getHostProjectPlacement(input.project, input.serverId)?.worktreeSupport ?? "unknown"; +} + export function getHostProjectSourceDirectory( project: HostProjectListItem, serverId: string, @@ -121,7 +128,7 @@ export function canCreateWorkspaceForHostProject(input: { if (!host) { return false; } - return input.allowAllProjects || host.canCreateWorktree; + return input.allowAllProjects || host.worktreeSupport !== "unsupported"; } export function filterWorkspaceProjectsForHost(input: { diff --git a/packages/app/src/projects/host-projects.test.ts b/packages/app/src/projects/host-projects.test.ts index 6267ea267e..dc07b17267 100644 --- a/packages/app/src/projects/host-projects.test.ts +++ b/packages/app/src/projects/host-projects.test.ts @@ -4,6 +4,7 @@ import { canCreateWorkspaceForHostProject, getHostProjectId, getHostProjectSourceDirectory, + getWorktreeSupportForHostProject, hostProjectFromRoute, } from "./host-project-model"; @@ -19,13 +20,13 @@ function project(): HostProjectListItem { serverId: "host-a", projectId: "prj_a", iconWorkingDir: "/repo/a", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, { serverId: "host-b", projectId: "prj_b", iconWorkingDir: "/repo/b", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaceKeys: [], @@ -49,6 +50,38 @@ describe("host project lookups", () => { ).toBe(true); }); + test("checks worktree capability against the selected host placement", () => { + const groupedProject = project(); + groupedProject.hosts[1] = { + ...groupedProject.hosts[1]!, + worktreeSupport: "unsupported" as const, + }; + + expect(getWorktreeSupportForHostProject({ project: groupedProject, serverId: "host-a" })).toBe( + "supported", + ); + expect(getWorktreeSupportForHostProject({ project: groupedProject, serverId: "host-b" })).toBe( + "unsupported", + ); + expect(getWorktreeSupportForHostProject({ project: groupedProject, serverId: "missing" })).toBe( + "unknown", + ); + }); + + test("marks route placeholder worktree support as unknown", () => { + const routeProject = hostProjectFromRoute({ + serverId: "host-a", + projectId: "prj_a", + displayName: "App", + sourceDirectory: "/repo/a", + }); + expect(routeProject).not.toBeNull(); + expect(routeProject!.projectKind).toBe("unknown"); + expect(getWorktreeSupportForHostProject({ project: routeProject!, serverId: "host-a" })).toBe( + "unknown", + ); + }); + test("builds an unhydrated route project around the routed project id", () => { expect( hostProjectFromRoute({ diff --git a/packages/app/src/projects/host-projects.ts b/packages/app/src/projects/host-projects.ts index 094a459b0a..3d874bedb4 100644 --- a/packages/app/src/projects/host-projects.ts +++ b/packages/app/src/projects/host-projects.ts @@ -7,6 +7,7 @@ export { filterWorkspaceProjectsForHost, getHostProjectSourceDirectory, getHostProjectId, + getWorktreeSupportForHostProject, hostProjectFromRoute, hostProjectFromWorkspace, resolveHostProjectCandidate, diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index db25238c31..9cd08cc545 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -5,14 +5,14 @@ export interface WorkspaceStructureHostPlacement { serverId: string; projectId: string; iconWorkingDir: string; - canCreateWorktree: boolean; + worktreeSupport: "supported" | "unsupported" | "unknown"; } export interface WorkspaceStructureProject { viewKey: string; projectKey: string | null; projectName: string; - projectKind: WorkspaceDescriptor["projectKind"]; + projectKind: WorkspaceDescriptor["projectKind"] | "unknown"; iconWorkingDir: string; hosts: WorkspaceStructureHostPlacement[]; workspaceKeys: string[]; @@ -157,7 +157,7 @@ function addProjectToView(input: { serverId, projectId: project.projectId, iconWorkingDir: project.projectRootPath, - canCreateWorktree: project.projectKind === "git", + worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported", }; const draft = byProject.get(viewKey); if (!draft) { diff --git a/packages/app/src/screens/new-workspace-initial-context.test.ts b/packages/app/src/screens/new-workspace-initial-context.test.ts index 969aae6d90..8b18c54439 100644 --- a/packages/app/src/screens/new-workspace-initial-context.test.ts +++ b/packages/app/src/screens/new-workspace-initial-context.test.ts @@ -13,7 +13,14 @@ function projectFor(serverId: string, key = "project"): HostProjectListItem { projectName: key, projectKind: "git", iconWorkingDir: `/work/${key}`, - hosts: [{ serverId, projectId: key, iconWorkingDir: `/work/${key}`, canCreateWorktree: true }], + hosts: [ + { + serverId, + projectId: key, + iconWorkingDir: `/work/${key}`, + worktreeSupport: "supported" as const, + }, + ], workspaceKeys: [], }; } diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts index 23d3edae9d..0d223dd321 100644 --- a/packages/app/src/screens/new-workspace-picker-item.test.ts +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; -import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item"; +import type { CheckoutStatusPayload } from "@/git/checkout-status-cache"; +import { + pickerItemToCheckoutRequest, + resolveCheckoutRequest, + type PickerItem, +} from "./new-workspace-picker-item"; const prItem: ForgeSearchItem = { kind: "change_request", @@ -82,3 +87,31 @@ describe("pickerItemToCheckoutRequest", () => { }); }); }); + +describe("resolveCheckoutRequest", () => { + const checkoutStatus = { + currentBranch: "feature/current", + } as CheckoutStatusPayload; + + it("branches from the loaded current branch when nothing was picked", () => { + expect(resolveCheckoutRequest(null, checkoutStatus)).toEqual({ + action: "branch-off", + refName: "feature/current", + }); + }); + + it("prefers an explicit picker selection", () => { + expect( + resolveCheckoutRequest({ kind: "branch", name: "feature/picked" }, checkoutStatus), + ).toEqual({ + action: "branch-off", + refName: "feature/picked", + }); + }); + + it("preserves the server-default intent for a detached checkout", () => { + expect( + resolveCheckoutRequest(null, { ...checkoutStatus, currentBranch: null }), + ).toBeUndefined(); + }); +}); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index f276510eae..32371e9420 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -1,5 +1,6 @@ import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { ForgeSearchItem } from "@getpaseo/protocol/messages"; +import type { CheckoutStatusPayload } from "@/git/checkout-status-cache"; export type PickerItem = | { kind: "branch"; name: string } @@ -43,3 +44,16 @@ export function pickerItemToCheckoutRequest( } } } + +export function resolveCheckoutRequest( + selectedItem: PickerItem | null, + checkoutStatus: CheckoutStatusPayload, +): PickerCheckoutRequest | undefined { + const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem); + if (selectedCheckoutRequest) return selectedCheckoutRequest; + if (!checkoutStatus.currentBranch) return undefined; + return { + action: "branch-off", + refName: checkoutStatus.currentBranch, + }; +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 6948f8370d..eb541c325a 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -8,7 +8,7 @@ import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createNameId } from "mnemonic-id"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; @@ -31,6 +31,8 @@ import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from " import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useForgeSearchQuery } from "@/git/use-forge-search-query"; +import { useCheckoutStatusQuery } from "@/git/use-status-query"; +import { ensureCheckoutStatus } from "@/git/checkout-status-cache"; import { useHostRuntimeClient, useHostRuntimeConnectionStatuses, @@ -67,6 +69,7 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp import { getHostProjectSourceDirectory, getHostProjectId, + getWorktreeSupportForHostProject, hostProjectFromRoute, hostProjectFromWorkspace, useHostProjects, @@ -87,7 +90,7 @@ import { remapDraftCwdToWorkspace, } from "./new-workspace-fork-context"; import { - pickerItemToCheckoutRequest, + resolveCheckoutRequest, type PickerCheckoutRequest, type PickerItem, } from "./new-workspace-picker-item"; @@ -109,19 +112,6 @@ const addProjectIcon = ( ); -function resolveCheckoutRequest( - selectedItem: PickerItem | null, - currentBranch: string | null, -): PickerCheckoutRequest | undefined { - const selectedCheckoutRequest = pickerItemToCheckoutRequest(selectedItem); - if (selectedCheckoutRequest) return selectedCheckoutRequest; - if (!currentBranch) return undefined; - return { - action: "branch-off", - refName: currentBranch, - }; -} - function useIsNewWorkspaceDraftHandoffActive(input: { draftId: string | undefined; selectedServerId: string; @@ -564,7 +554,8 @@ function NewWorkspaceProjectPickerOption({ active={active} disabled={ isPending || - (!supportsWorkspaceMultiplicity && !project.hosts.some((host) => host.canCreateWorktree)) + (!supportsWorkspaceMultiplicity && + !project.hosts.some((host) => host.worktreeSupport !== "unsupported")) } onPress={onPress} /> @@ -697,21 +688,20 @@ interface WorkspaceIsolationState { showRefPicker: boolean; } -// Worktree isolation only makes sense for a git checkout. The effective isolation -// falls back to local whenever the selected directory isn't git so the flow -// never submits an impossible request. +// Preserve the user's worktree choice while route metadata is provisional. Once +// the authoritative placement arrives, unsupported projects fall back to local. function useWorkspaceIsolation(input: { supportsMultiplicity: boolean; - selectedIsGit: boolean; + worktreeSupport: "supported" | "unsupported" | "unknown"; }): WorkspaceIsolationState { - const { supportsMultiplicity, selectedIsGit } = input; + const { supportsMultiplicity, worktreeSupport } = input; // The last isolation choice is remembered alongside the other New Workspace // form preferences (provider, model, mode). A manual in-screen pick overrides // the remembered default until the screen remounts. const { preferences, updatePreferences } = useFormPreferences(); const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null); const isolation = manualIsolation ?? preferences.isolation ?? "local"; - const canCreateWorktree = supportsMultiplicity && selectedIsGit; + const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported"; const isWorktree = isolation === "worktree" && canCreateWorktree; const setIsolation = useCallback( @@ -812,8 +802,7 @@ async function createMultiplicityWorkspace(input: { isolation: "local" | "worktree"; project: HostProjectListItem; sourceDirectory: string; - selectedItem: PickerItem | null; - currentBranch: string | null; + checkoutRequest: PickerCheckoutRequest | undefined; withInitialAgent: boolean; prompt: string; attachments: AgentAttachment[]; @@ -827,9 +816,6 @@ async function createMultiplicityWorkspace(input: { const projectId = getHostProjectId(input.project, input.serverId); if (!projectId) throw new Error("Project is not available on the selected host"); const isWorktree = input.isolation === "worktree"; - const checkoutRequest = isWorktree - ? resolveCheckoutRequest(input.selectedItem, input.currentBranch) - : undefined; const firstAgentContext = buildFirstAgentContext({ prompt: input.prompt, attachments: input.attachments, @@ -841,7 +827,7 @@ async function createMultiplicityWorkspace(input: { cwd: input.sourceDirectory, projectId, worktreeSlug: createNameId(), - ...checkoutRequest, + ...input.checkoutRequest, } : { kind: "directory", @@ -1518,6 +1504,7 @@ export function NewWorkspaceScreen({ displayName: displayNameProp, draftId, }: NewWorkspaceScreenProps) { + const queryClient = useQueryClient(); const { theme } = useUnistyles(); const { t } = useTranslation(); const insets = useSafeAreaInsets(); @@ -1567,7 +1554,6 @@ export function NewWorkspaceScreen({ }, [pickerSearchQuery]); const workspace = createdWorkspace; - const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); const client = useHostRuntimeClient(selectedServerId); const isConnected = useHostRuntimeIsConnected(selectedServerId); const { @@ -1646,27 +1632,20 @@ export function NewWorkspaceScreen({ const hasSelectedSourceDirectory = selectedSourceDirectory !== null; const pickerQueryEnabled = pickerOpen && clientReady && hasSelectedSourceDirectory; - const checkoutStatusQuery = useQuery({ - queryKey: ["checkout-status", selectedServerId, selectedSourceDirectory], - queryFn: async () => { - if (!selectedSourceDirectory) { - throw new Error("Choose a project"); - } - const connectedClient = withConnectedClient(); - return connectedClient.getCheckoutStatus(selectedSourceDirectory); - }, - enabled: clientReady && hasSelectedSourceDirectory, - staleTime: Infinity, - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, + const { status: checkoutStatus } = useCheckoutStatusQuery({ + serverId: selectedServerId, + cwd: selectedSourceDirectory ?? "", }); - const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null; + const currentBranch = checkoutStatus?.currentBranch ?? null; + const worktreeSupport = selectedProject + ? getWorktreeSupportForHostProject({ project: selectedProject, serverId: selectedServerId }) + : "unsupported"; + const isPending = isNewWorkspacePending({ pendingAction, isDraftHandoffActive }); const { effectiveIsolation, setIsolation, canCreateWorktree, showRefPicker } = useWorkspaceIsolation({ supportsMultiplicity: supportsWorkspaceMultiplicity, - selectedIsGit: checkoutStatusQuery.data?.isGit === true, + worktreeSupport, }); const branchSuggestionsQuery = useQuery({ @@ -1885,6 +1864,7 @@ export function NewWorkspaceScreen({ cwd: string; prompt: string; attachments: AgentAttachment[]; + checkoutRequest: PickerCheckoutRequest | undefined; }): CreatePaseoWorktreeInput => { if (!selectedProject) { throw new Error("Choose a project"); @@ -1892,7 +1872,6 @@ export function NewWorkspaceScreen({ if (!selectedSourceDirectory) { throw new Error("Choose a host for this project"); } - const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch); const firstAgentContext = buildFirstAgentContext(input); const hostProjectId = getHostProjectId(selectedProject, selectedServerId); if (!hostProjectId) { @@ -1904,10 +1883,10 @@ export function NewWorkspaceScreen({ projectId: hostProjectId, worktreeSlug: createNameId(), ...(firstAgentContext ? { firstAgentContext } : {}), - ...checkoutRequest, + ...input.checkoutRequest, }; }, - [currentBranch, selectedItem, selectedProject, selectedServerId, selectedSourceDirectory], + [selectedProject, selectedServerId, selectedSourceDirectory], ); const ensureWorkspace = useCallback( @@ -1926,14 +1905,26 @@ export function NewWorkspaceScreen({ if (!selectedSourceDirectory) { throw new Error("Choose a host for this project"); } + const connectedClient = withConnectedClient(); + const createsWorktree = !supportsWorkspaceMultiplicity || effectiveIsolation === "worktree"; + const checkoutRequest = createsWorktree + ? resolveCheckoutRequest( + selectedItem, + await ensureCheckoutStatus({ + queryClient, + client: connectedClient, + serverId: selectedServerId, + cwd: selectedSourceDirectory, + }), + ) + : undefined; const normalizedWorkspace = supportsWorkspaceMultiplicity ? await createMultiplicityWorkspace({ - client: withConnectedClient(), + client: connectedClient, isolation: effectiveIsolation, project: selectedProject, sourceDirectory: selectedSourceDirectory, - selectedItem, - currentBranch, + checkoutRequest, withInitialAgent: input.withInitialAgent, prompt: input.prompt, attachments: input.attachments, @@ -1942,8 +1933,8 @@ export function NewWorkspaceScreen({ createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), }) : await createAndMergeWorkspace({ - client: withConnectedClient(), - createInput: buildCreateWorktreeInput(input), + client: connectedClient, + createInput: buildCreateWorktreeInput({ ...input, checkoutRequest }), mergeWorkspaces, serverId: selectedServerId, createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"), @@ -1954,9 +1945,9 @@ export function NewWorkspaceScreen({ [ buildCreateWorktreeInput, createdWorkspace, - currentBranch, effectiveIsolation, mergeWorkspaces, + queryClient, selectedItem, selectedProject, selectedServerId, diff --git a/packages/app/src/screens/new-workspace/project-picker.ts b/packages/app/src/screens/new-workspace/project-picker.ts index 54f4ab7039..0d52b4dfa8 100644 --- a/packages/app/src/screens/new-workspace/project-picker.ts +++ b/packages/app/src/screens/new-workspace/project-picker.ts @@ -166,7 +166,11 @@ export function useNewWorkspaceProjectPicker({ (id: string) => { const project = projectByOptionId.get(id); if (!project) return; - if (!allowAllProjects && !project.hosts.some((host) => host.canCreateWorktree)) return; + if ( + !allowAllProjects && + !project.hosts.some((host) => host.worktreeSupport !== "unsupported") + ) + return; setProjectSelection({ contextKey: manualSelectionContextKey, project, diff --git a/packages/app/src/screens/new-workspace/project-selection.test.ts b/packages/app/src/screens/new-workspace/project-selection.test.ts index 0ec8caea8a..2f4ea240b5 100644 --- a/packages/app/src/screens/new-workspace/project-selection.test.ts +++ b/packages/app/src/screens/new-workspace/project-selection.test.ts @@ -23,7 +23,7 @@ function project(projectKey: string, serverId = "host"): HostProjectListItem { serverId, projectId: projectKey, iconWorkingDir: `/work/${projectKey}`, - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaceKeys: [], @@ -127,7 +127,7 @@ describe("reconcileProjectSelection", () => { serverId: "host", projectId: "local-project-id", iconWorkingDir: "/work/project", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; @@ -138,7 +138,7 @@ describe("reconcileProjectSelection", () => { serverId: "host", projectId: "local-project-id", iconWorkingDir: "/work/project", - canCreateWorktree: false, + worktreeSupport: "unsupported" as const, }, ], }; @@ -301,7 +301,7 @@ describe("reconcileProjectSelection", () => { serverId: "host-b", projectId: "prj_a", iconWorkingDir: "/work/a", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; @@ -338,7 +338,7 @@ describe("reconcileProjectSelection", () => { serverId: "host-a", projectId: "prj_b", iconWorkingDir: "/work/selected", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; @@ -421,7 +421,7 @@ describe("reconcileProjectSelection", () => { serverId: "host-a", projectId: "prj_b", iconWorkingDir: "/work/selected", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; @@ -466,7 +466,7 @@ describe("reconcileProjectSelection", () => { serverId: "host-b", projectId: "shared", iconWorkingDir: "/work/shared", - canCreateWorktree: false, + worktreeSupport: "unsupported" as const, }, ], }; @@ -619,7 +619,7 @@ describe("reconcileProjectSelection", () => { serverId: "host", projectId: "sibling", iconWorkingDir: "/work/sibling", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], }; diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 429502014c..6101963f73 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -44,7 +44,11 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry { const projectKind = overrides.projectKind ?? "git"; const hosts = Array.from( overrides.hosts ?? [ - { serverId: "srv", iconWorkingDir: "/repo", canCreateWorktree: projectKind === "git" }, + { + serverId: "srv", + iconWorkingDir: "/repo", + worktreeSupport: projectKind === "git" ? "supported" : "unsupported", + }, ], (host) => Object.assign({}, host, { projectId: host.projectId ?? `project-${host.serverId}` }), ); @@ -135,8 +139,12 @@ describe("buildSidebarProjectRowModel", () => { const result = buildSidebarProjectRowModel({ project: project({ hosts: [ - { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }, - { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, + { + serverId: "host-a", + iconWorkingDir: "/repo/a", + worktreeSupport: "unsupported" as const, + }, + { serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const }, ], }), collapsed: false, @@ -155,8 +163,16 @@ describe("buildSidebarProjectRowModel", () => { project: project({ projectKind: "directory", hosts: [ - { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: false }, - { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: false }, + { + serverId: "host-a", + iconWorkingDir: "/repo/a", + worktreeSupport: "unsupported" as const, + }, + { + serverId: "host-b", + iconWorkingDir: "/repo/b", + worktreeSupport: "unsupported" as const, + }, ], }), collapsed: false, @@ -197,8 +213,8 @@ describe("buildSidebarProjectRowModel", () => { const iconTarget = resolveSidebarProjectIconTarget( project({ hosts: [ - { serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }, - { serverId: "host-a", iconWorkingDir: "/repo/a", canCreateWorktree: true }, + { serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const }, + { serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" as const }, ], }), ); @@ -214,7 +230,9 @@ describe("buildSidebarProjectRowModel", () => { const [iconTarget] = resolveSidebarProjectIconTargets([ project({ viewKey: '["placement","host-b","project-b"]', - hosts: [{ serverId: "host-b", iconWorkingDir: "/repo/b", canCreateWorktree: true }], + hosts: [ + { serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" as const }, + ], }), ]); @@ -230,8 +248,12 @@ describe("buildSidebarProjectRowModel", () => { const groupedProject = project({ iconWorkingDir: "/remote/repo", hosts: [ - { serverId: "remote", iconWorkingDir: "/remote/repo", canCreateWorktree: true }, - { serverId: "local", iconWorkingDir: "/local/repo", canCreateWorktree: true }, + { + serverId: "remote", + iconWorkingDir: "/remote/repo", + worktreeSupport: "supported" as const, + }, + { serverId: "local", iconWorkingDir: "/local/repo", worktreeSupport: "supported" as const }, ], }); diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts index 0333ca3d10..10c0943c19 100644 --- a/packages/app/src/utils/sidebar-project-row-model.ts +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -75,7 +75,10 @@ function resolveNewWorkspaceTarget( supportsMultiplicityByServerId: ReadonlyMap, ): SidebarProjectHostTarget | null { for (const host of project.hosts) { - if (!host.canCreateWorktree && !supportsMultiplicityByServerId.get(host.serverId)) { + if ( + host.worktreeSupport === "unsupported" && + !supportsMultiplicityByServerId.get(host.serverId) + ) { continue; } const target = hostTarget(host); diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 7e91d52433..196705c2b3 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -55,7 +55,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb serverId: workspaces[0]?.serverId ?? "s1", projectId: projectKey, iconWorkingDir: workspaces[0]?.workspaceDirectory ?? "", - canCreateWorktree: true, + worktreeSupport: "supported" as const, }, ], workspaces, @@ -150,7 +150,7 @@ describe("buildSidebarShortcutModel", () => { directoryProject.projectKind = "directory"; directoryProject.hosts = directoryProject.hosts.map((host) => ({ ...host, - canCreateWorktree: false, + worktreeSupport: "unsupported" as const, })); const model = buildSidebarShortcutModel({ From 706ae3d9bb5700c7f2ba1235009011c001393e82 Mon Sep 17 00:00:00 2001 From: Ethan Greenfeld <39423472+ebg1223@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:22:44 -0700 Subject: [PATCH 35/46] Read Claude subagents from the SDK task protocol (#2498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(claude): derive subagents from the SDK task protocol Claude Code announces subagent identity and lifecycle on the SDK stream (task_started, task_updated, task_notification). Paseo consumed only task_notification and reconstructed the rest from sidechain frames, in two independent derivations that had drifted apart. Both paths now translate into one small observation vocabulary and fold it with one function, so identity and status are derived once. The fold is stateless: the task protocol is edge-triggered and the store's sticky merge already accumulates. - Replay links a subagent through the agent-.meta.json sidecar, which carries the same Task tool_use id the live stream keys on. The agentId text scrape stays as a fallback for sessions recorded without it. - The sidechain tracker keeps mapping frames to timeline items but no longer owns the descriptor when the task protocol is active, detected from the session rather than from a version string. - Adds replay coverage, which did not exist. Subagent ids are unchanged, so no protocol, store, or client change. Co-Authored-By: Claude Opus 5 (1M context) * fix(claude): keep non-subagent tasks out of the track, open the child timeline with its prompt Backgrounded shell commands announce as task_started with a tool_use_id but task_type local_bash and no subagent_type, so filtering on the id alone put `sleep 20` in the subagents track. Discriminate on task_type, falling back to subagent_type for releases that predate it. task_started also carries the prompt. Emitting it as the child's first timeline item means the pane no longer opens mid-conversation, and matches replay, where the sidechain transcript already begins with the prompt. Co-Authored-By: Claude Opus 5 (1M context) * fix(claude): drop status for tasks that were never declared as subagents task_notification also fires for backgrounded shell commands and carries a tool_use_id. Routing status off that id readmitted exactly what the declaration filter rejected, as a descriptor with a status and no identity — rendered in the track as a nameless "Loading..." row. Status now routes only through the task_id map populated at declaration. Co-Authored-By: Claude Opus 5 (1M context) * fix(app): name a subagent row after its task, not its type The descriptor carries the subagent type in title and the task in description. The client collapsed them and rendered only the type, so every child in a fan-out read "general-purpose". The task is what tells siblings apart, so it names the row and the type moves to the muted secondary line, which the track computed but never drew. Providers that report no task keep the previous behavior. Co-Authored-By: Claude Opus 5 (1M context) * fix(claude): label the parent's Task card from the subagent declaration A backgrounded Task subagent emits no frames carrying parent_tool_use_id, so the sidechain tracker never runs for one and never supplies the sub_agent detail. Its card in the parent transcript rendered as a bare "Task". The declaration already carries the type and the task, so the card is built from it. For a foreground subagent the tracker still emits the same card enriched with the child's action log; the two collapse on the shared tool-call id, so that case is unchanged. Co-Authored-By: Claude Opus 5 (1M context) * feat(subagents): surface a subagent's model, effort, and cost Adds model, effort and usage to the provider subagent descriptor, populated from observations the child itself reports. - model: read per assistant frame, live and on replay. Never snapshotted, since a provider can swap models mid-flight on overload or refusal fallback, and never inherited from the parent. - effort: replay only. The SDK's assistant message does not carry it and it can be silently downgraded for the chosen model, so a live value would be a guess. - usage: task_progress reports tokens and tool count once per tool use, and task_notification reports the terminal totals. This matters most for backgrounded subagents, which emit no sidechain frames and would otherwise show no sign of activity at all. Status becomes optional on the upsert event: a report that only carries a model or a cost says nothing about whether the child is still running, and must not revert a finished one. Usage merges rather than replaces, because duration only arrives at the end. Fields are optional on the wire, so an older daemon simply omits them and the client renders nothing rather than substituting the parent's settings. Co-Authored-By: Claude Opus 5 (1M context) * feat(claude): read subagent effort from hooks Effort appears nowhere on the SDK message stream — verified by scanning every message type at depth — and the level Paseo requests is not necessarily the one that runs, since a model that does not support it is silently downgraded. Hooks are the source. Every hook input carries agent_id and effort.level, the active level for that turn after any downgrade, and agent_id is the same id task_started calls task_id, so it routes through the table already built at declaration. PreToolUse, PostToolUse and SubagentStop are registered purely to observe; they record what they see and always return empty, so they cannot affect tool execution or turn control. Paseo's hooks merge with any the user configured rather than replacing them: extraClaudeOptions is spread after the base options, so assigning would have silently stopped effort being observed for anyone with their own hooks. Verified end to end through ClaudeAgentClient against a real session: a subagent descriptor now carries model, effort and usage together. Co-Authored-By: Claude Opus 5 (1M context) * feat(subagents): show a subagent's runtime facts in its pane, and its cost on replay The track row carried model, effort and cost; the pane carried none of it, so opening a subagent showed only its task. The pane gets a header strip rather than a longer tab subtitle: the subtitle feeds the width-constrained tab strip, whose job is to name the task. Duration appears in the pane, which has room, and stays out of the dense row. Replay had no usage source, so a reopened agent lost its subagents' token and tool counts permanently — descriptors are in-memory and cleared when an agent closes. Usage is now derived from the child's own transcript, matching the live producer exactly: total tokens are the four counters summed on the last assistant turn (a context reading, not a cumulative sum, which would multiply the cached prefix by the turn count), and tool uses are tool_use blocks across assistant entries. Duration is an approximation — the transcript's first entry is written after the task starts — and is the one field replay cannot match. Co-Authored-By: Claude Opus 5 (1M context) * docs(claude): record the task protocol's undocumented wire facts The subagent sources depend on Claude Code internals the SDK types do not explain: which announced tasks are subagents, why a status for an undeclared task is dropped, why task ids outlive a turn, why effort is only reachable through hooks, and why a replayed session must skip its grandchildren. Co-Authored-By: Claude Opus 5 (1M context) * Generalize provider subagent presentation --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Mohamed Boudra --- docs/agent-lifecycle.md | 14 + .../src/panels/provider-subagent-panel.tsx | 8 +- packages/app/src/subagents/select.test.ts | 9 + packages/app/src/subagents/select.ts | 15 +- .../src/subagents/track-presentation.test.ts | 100 +++- .../app/src/subagents/track-presentation.ts | 12 +- packages/app/src/subagents/track.tsx | 13 + packages/protocol/src/messages.ts | 3 + .../server/agent/provider-subagents/store.ts | 30 +- .../claude/agent.background-subagent.test.ts | 183 +++++++ .../claude/agent.sub-agent-replay.test.ts | 226 ++++++++ .../claude/agent.subagent-interrupt.test.ts | 183 +++++++ .../server/agent/providers/claude/agent.ts | 323 ++++++++---- .../agent/providers/claude/hooks.test.ts | 36 ++ .../server/agent/providers/claude/hooks.ts | 21 + .../server/agent/providers/claude/models.ts | 29 + .../providers/claude/sidechain-tracker.ts | 47 +- .../claude/subagents/live-source.test.ts | 489 +++++++++++++++++ .../providers/claude/subagents/live-source.ts | 395 ++++++++++++++ .../claude/subagents/observation.test.ts | 139 +++++ .../providers/claude/subagents/observation.ts | 91 ++++ .../claude/subagents/presentation.test.ts | 31 ++ .../claude/subagents/presentation.ts | 53 ++ .../claude/subagents/replay-source.test.ts | 497 ++++++++++++++++++ .../claude/subagents/replay-source.ts | 312 +++++++++++ .../providers/omp/test-utils/omp-harness.ts | 4 +- 26 files changed, 3140 insertions(+), 123 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/hooks.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/hooks.ts create mode 100644 packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/subagents/live-source.ts create mode 100644 packages/server/src/server/agent/providers/claude/subagents/observation.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/subagents/observation.ts create mode 100644 packages/server/src/server/agent/providers/claude/subagents/presentation.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/subagents/presentation.ts create mode 100644 packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/subagents/replay-source.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 6801223910..cc6f436265 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -123,6 +123,20 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. +Provider descriptors may include one compact subtitle. The provider owns its contents and formatting; clients display and truncate it without interpreting provider-specific model, thinking, or usage fields. + +### Claude provider subagents: the task protocol + +Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `task_updated` / `task_notification` / `task_progress`), and Paseo reads those announcements rather than reconstructing them from sidechain frames. The live source (`subagents/live-source.ts`) and the replay source (`subagents/replay-source.ts`) both translate into one observation vocabulary (`subagents/observation.ts`), so a fact is derived once for both paths instead of once per path. Gotchas that are not obvious from the SDK types: + +- **Not every announced task is a subagent.** A backgrounded shell announces as `task_type: "local_bash"` with a `tool_use_id` and no `subagent_type`; workflows announce as `local_workflow`; ambient housekeeping sets `skip_transcript`. Filtering on the presence of a `tool_use_id` alone puts `sleep 20` in the subagents track. +- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table. +- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`. +- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`. +- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them. +- **On replay, `/subagents/` holds every descendant, not just this session's children.** `agent-.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22. +- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path. + Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls. The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track. diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index a5bac3b7bf..f33f1beab3 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -45,10 +45,14 @@ function useProviderSubagentDescriptor( (state) => state.sessions[context.serverId]?.agents.get(target.parentAgentId)?.provider, ); const provider = descriptor?.provider ?? parentProvider ?? "agent"; - const label = descriptor?.title?.trim() || descriptor?.description?.trim() || "Subagent"; + // The task names the tab; the subagent type is supporting detail beside the provider. + const subagentType = descriptor?.title?.trim(); + const label = descriptor?.description?.trim() || subagentType || "Subagent"; + const providerLabel = `${formatProviderLabel(provider)} subagent`; return { label, - subtitle: `${formatProviderLabel(provider)} subagent`, + subtitle: + subagentType && subagentType !== label ? `${subagentType} · ${providerLabel}` : providerLabel, tooltip: label, titleState: descriptor ? "ready" : "loading", icon: getProviderIcon(provider), diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 7545ea27c3..ae110167ad 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -76,6 +76,7 @@ describe("selectSubagentsForParent", () => { provider: "codex", title: "Provider child", description: null, + subtitle: "Codex worker · 4.2k tokens", status: "completed", createdAt: "2026-03-08T10:01:00.000Z", updatedAt: "2026-03-08T10:02:00.000Z", @@ -92,6 +93,10 @@ describe("selectSubagentsForParent", () => { (row) => row.id, ), ).toEqual(["provider-child"]); + expect( + selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, true)[0] + ?.subtitle, + ).toBe("Codex worker · 4.2k tokens"); }); it("hides locally dismissed provider children while retaining their descriptor", () => { @@ -259,6 +264,8 @@ describe("selectSubagentsForParent", () => { id: "child", provider: "claude", title: "Review child", + description: null, + subtitle: null, status: "running", requiresAttention: true, createdAt, @@ -266,11 +273,13 @@ describe("selectSubagentsForParent", () => { ]); expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ "createdAt", + "description", "id", "kind", "provider", "requiresAttention", "status", + "subtitle", "title", ]); expect(rows[0]).not.toHaveProperty("onOpen"); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index d96c1628af..cc07480b33 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -11,6 +11,9 @@ export interface PaseoSubagentRow { id: Agent["id"]; provider: Agent["provider"]; title: Agent["title"]; + /** Managed agents have a real title, so the union's task line is always absent for them. */ + description: null; + subtitle: null; status: Agent["status"]; requiresAttention: Agent["requiresAttention"]; createdAt: Agent["createdAt"]; @@ -21,7 +24,13 @@ export interface ProviderSubagentRow { id: string; parentAgentId: string; provider: ProviderSubagentDescriptorPayload["provider"]; + // `title` is the subagent type ("Explore", "general-purpose") and repeats across a fan-out; + // `description` is the task it was given. Both are carried so presentation can choose which + // one names the row — collapsing them here is what makes every row read alike. title: string | null; + description: string | null; + /** Compact provider-owned context. The app displays it without interpreting its contents. */ + subtitle: string | null; status: ProviderSubagentDescriptorPayload["status"]; requiresAttention: boolean; createdAt: Date; @@ -46,6 +55,8 @@ function toSubagentRow(agent: Agent): SubagentRow { id: agent.id, provider: agent.provider, title: agent.title, + description: null, + subtitle: null, status: agent.status, requiresAttention: agent.requiresAttention, createdAt: agent.createdAt, @@ -97,7 +108,9 @@ export function selectProviderSubagentsForParent( id: subagent.id, parentAgentId: subagent.parentAgentId, provider: subagent.provider, - title: subagent.title ?? subagent.description, + title: subagent.title, + description: subagent.description, + subtitle: subagent.subtitle ?? null, status: subagent.status, requiresAttention: subagent.status === "failed", createdAt: new Date(subagent.createdAt), diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 5f74fd6bdf..0dfd1b3cf7 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { PaseoSubagentRow, SubagentRow } from "./select"; +import type { PaseoSubagentRow, ProviderSubagentRow, SubagentRow } from "./select"; import { buildSubagentRowPresentationData, countFinishedSubagents, @@ -15,6 +15,8 @@ function row( id: overrides.id, provider: overrides.provider ?? "codex", title: overrides.title ?? `Agent ${overrides.id}`, + description: null, + subtitle: null, status: overrides.status ?? "idle", requiresAttention: overrides.requiresAttention ?? false, createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), @@ -82,6 +84,8 @@ describe("countFinishedSubagents", () => { parentAgentId: "parent", provider: "claude", title: "running", + description: null, + subtitle: null, status: "running", requiresAttention: false, createdAt: new Date("2026-04-20T00:00:00.000Z"), @@ -92,6 +96,8 @@ describe("countFinishedSubagents", () => { parentAgentId: "parent", provider: "claude", title: "failed", + description: null, + subtitle: null, status: "failed", requiresAttention: true, createdAt: new Date("2026-04-20T00:00:01.000Z"), @@ -166,3 +172,95 @@ describe("buildSubagentRowPresentationData", () => { ).toBe("done"); }); }); + +describe("buildSubagentRowPresentationData for provider rows", () => { + function providerRow(overrides: Partial = {}): ProviderSubagentRow { + return { + kind: "provider", + id: overrides.id ?? "toolu_1", + parentAgentId: "parent", + provider: "claude", + title: "title" in overrides ? (overrides.title ?? null) : "general-purpose", + description: overrides.description ?? null, + subtitle: overrides.subtitle ?? null, + status: overrides.status ?? "running", + requiresAttention: false, + createdAt: overrides.createdAt ?? new Date("2026-07-26T00:00:00.000Z"), + }; + } + + it("names the row after the task and demotes the subagent type", () => { + const presentation = buildSubagentRowPresentationData( + providerRow({ title: "general-purpose", description: "Reply with banana" }), + ); + expect(presentation.label).toBe("Reply with banana"); + expect(presentation.subtitle).toBe("general-purpose"); + }); + + it("tells two siblings of the same type apart", () => { + const left = buildSubagentRowPresentationData( + providerRow({ id: "a", description: "Summarize the docs" }), + ); + const right = buildSubagentRowPresentationData( + providerRow({ id: "b", description: "Reply with banana" }), + ); + expect(left.label).not.toBe(right.label); + }); + + it("keeps type-as-label and an empty subtitle when a provider reports no task", () => { + const presentation = buildSubagentRowPresentationData( + providerRow({ title: "Provider child", description: null }), + ); + expect(presentation.label).toBe("Provider child"); + expect(presentation.subtitle).toBe(""); + }); + + it("stays in the loading state when neither field is known", () => { + const presentation = buildSubagentRowPresentationData( + providerRow({ title: null, description: null }), + ); + expect(presentation.titleState).toBe("loading"); + }); + + it("leaves managed subagent rows with no subtitle", () => { + expect(buildSubagentRowPresentationData(row({ id: "a", title: "Managed" })).subtitle).toBe(""); + }); +}); + +describe("provider-owned row subtitles", () => { + function providerRow(overrides: Partial = {}): ProviderSubagentRow { + return { + kind: "provider", + id: "toolu_1", + parentAgentId: "parent", + provider: "claude", + title: "general-purpose", + description: "Reply with banana", + subtitle: null, + status: "running", + requiresAttention: false, + createdAt: new Date("2026-07-26T00:00:00.000Z"), + ...overrides, + }; + } + + it("displays provider context without interpreting it", () => { + expect( + buildSubagentRowPresentationData( + providerRow({ subtitle: "general-purpose · Opus 5 · High · 16.5k tokens" }), + ).subtitle, + ).toBe("general-purpose · Opus 5 · High · 16.5k tokens"); + }); + + it("falls back to the type when an older provider sends no subtitle", () => { + expect(buildSubagentRowPresentationData(providerRow()).subtitle).toBe("general-purpose"); + }); + + it("does not duplicate the type when it is already the primary label", () => { + expect( + buildSubagentRowPresentationData( + providerRow({ description: null, subtitle: null, title: "general-purpose" }), + ).subtitle, + ).toBe(""); + }); +}); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index c4baa42f8f..b1a0fc3b75 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -18,13 +18,19 @@ export interface SubagentRowPresentationData { } export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData { - const label = resolveRowLabel(row.title); + // The task distinguishes siblings in a fan-out, so it names the row when present. Providers + // own the compact secondary context because model, effort, and usage semantics differ. + const description = resolveRowLabel(row.description); + const title = resolveRowLabel(row.title); + const label = description ?? title; + const providerSubtitle = row.kind === "provider" ? resolveRowLabel(row.subtitle) : null; + const subtitle = providerSubtitle ?? (description ? title : null); const status = presentationStatus(row); return { key: `${row.kind}_subagent_${row.id}`, kind: "agent", label: label ?? "", - subtitle: "", + subtitle: subtitle ?? "", titleState: label ? "ready" : "loading", statusBucket: deriveSidebarStateBucket({ status, @@ -52,7 +58,7 @@ export function countFinishedSubagents(rows: readonly SubagentRow[]): number { return rows.filter((row) => row.kind === "provider" && row.status !== "running").length; } -export function resolveRowLabel(title: SubagentRow["title"]): string | null { +export function resolveRowLabel(title: string | null | undefined): string | null { if (typeof title !== "string") { return null; } diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 1aaa594364..b140b999a5 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -204,6 +204,11 @@ function SubagentsTrackRow({ {displayLabel} + {presentation.subtitle ? ( + + {presentation.subtitle} + + ) : null} {row.kind === "paseo" ? ( ({ fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, + // Keep provider context secondary and bounded so the task remains readable on compact screens. + rowSubtitle: { + flexShrink: 1, + minWidth: 0, + maxWidth: "45%", + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, actionClusterVisible: { flexDirection: "row", alignItems: "center", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index af44e09f90..114e78d383 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3558,6 +3558,9 @@ export const ProviderSubagentDescriptorPayloadSchema = z.object({ updatedAt: z.string(), toolCallId: z.string().nullable(), cwd: z.string().nullable().optional(), + // Compact provider-owned context for the shared track. Providers choose what belongs here and + // format it for display; clients must not parse provider-specific facts out of this string. + subtitle: z.string().nullable().optional(), }); export type ProviderSubagentDescriptorPayload = z.infer< diff --git a/packages/server/src/server/agent/provider-subagents/store.ts b/packages/server/src/server/agent/provider-subagents/store.ts index 0d05e64269..b29d9f6a52 100644 --- a/packages/server/src/server/agent/provider-subagents/store.ts +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -21,6 +21,7 @@ export interface ProviderSubagentDescriptor { updatedAt: string; toolCallId: string | null; cwd: string | null; + subtitle: string | null; } export type ProviderSubagentInputEvent = @@ -29,9 +30,14 @@ export type ProviderSubagentInputEvent = id: string; title?: string | null; description?: string | null; - status: ProviderSubagentStatus; + /** + * Omit to keep the stored status. A presentation-only upsert says nothing about whether the + * child is still running, and must not revert a finished one. + */ + status?: ProviderSubagentStatus; toolCallId?: string | null; cwd?: string | null; + subtitle?: string | null; timestamp?: string; } | { @@ -58,6 +64,15 @@ function storeKey(parentAgentId: string, subagentId: string): string { return `${parentAgentId}\0${subagentId}`; } +/** + * Sticky upsert semantics for a descriptor field: an omitted value preserves what is stored, an + * explicit `null` clears it. Providers observe these fields incrementally, so a partial upsert + * must never blank fields it says nothing about. + */ +function stickyField(next: T | undefined, previous: T | null | undefined): T | null { + return next === undefined ? (previous ?? null) : next; +} + export class ProviderSubagentStore { private readonly descriptors = new Map(); private readonly timelines = new InMemoryAgentTimelineStore(); @@ -100,15 +115,14 @@ export class ProviderSubagentStore { id: event.id, parentAgentId, provider, - title: event.title === undefined ? (previous?.title ?? null) : event.title, - description: - event.description === undefined ? (previous?.description ?? null) : event.description, - status: event.status, + title: stickyField(event.title, previous?.title), + description: stickyField(event.description, previous?.description), + status: event.status ?? previous?.status ?? "running", createdAt: previous?.createdAt ?? timestamp, updatedAt: timestamp, - toolCallId: - event.toolCallId === undefined ? (previous?.toolCallId ?? null) : event.toolCallId, - cwd: event.cwd === undefined ? (previous?.cwd ?? null) : event.cwd, + toolCallId: stickyField(event.toolCallId, previous?.toolCallId), + cwd: stickyField(event.cwd, previous?.cwd), + subtitle: stickyField(event.subtitle, previous?.subtitle), }; this.descriptors.set(key, subagent); return { type: "upsert", subagent }; diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts new file mode 100644 index 0000000000..0a77593435 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { streamSession } from "../test-utils/session-stream-adapter.js"; + +/** + * A backgrounded Task subagent emits NO frames carrying parent_tool_use_id — verified against + * Claude Code 2.1.220. Everything keyed off that field therefore sees nothing for one: no + * descriptor, no timeline, and a Task card in the parent transcript with no type or task on it. + * + * The task protocol still announces the subagent, so these assert that a background child is + * visible on both surfaces from its declaration alone. + */ + +const queryFactory = vi.fn(); + +function buildQueryMock(events: unknown[]) { + let index = 0; + return { + next: vi.fn(async () => { + if (index >= events.length) return { done: true, value: undefined }; + const value = events[index]; + index += 1; + return { done: false, value }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + close: vi.fn(() => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => []), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +// No frame carries parent_tool_use_id: that is the defining shape of a background subagent. +const BACKGROUND_SUBAGENT_STREAM = [ + { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, + { + type: "system", + subtype: "task_started", + task_id: "aa482d02957fe96c8", + tool_use_id: "toolu_01TVF5JXom1yoZVoiaEWAoUV", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Reply with banana", + prompt: "Reply with just the word: banana", + }, + { + type: "system", + subtype: "task_updated", + task_id: "aa482d02957fe96c8", + patch: { status: "completed" }, + }, + { + type: "result", + subtype: "success", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + total_cost_usd: 0, + }, +]; + +async function collectUntilTerminal( + stream: AsyncGenerator, +): Promise { + const events: AgentStreamEvent[] = []; + for await (const event of stream) { + events.push(event); + if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + break; + } + } + return events; +} + +describe("background Claude subagents", () => { + afterEach(() => { + queryFactory.mockReset(); + }); + + async function runStream(): Promise { + queryFactory.mockImplementation(() => buildQueryMock(BACKGROUND_SUBAGENT_STREAM)); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + return events; + } + + test("appears in the subagents track despite emitting no sidechain frames", async () => { + const upserts = (await runStream()) + .filter((event) => event.type === "provider_subagent") + .map((event) => event.event) + .filter((event) => event.type === "upsert"); + + expect(upserts[0]).toMatchObject({ + id: "toolu_01TVF5JXom1yoZVoiaEWAoUV", + toolCallId: "toolu_01TVF5JXom1yoZVoiaEWAoUV", + title: "general-purpose", + description: "Reply with banana", + }); + expect(upserts.at(-1)).toMatchObject({ status: "completed" }); + }); + + test("opens the child timeline with the prompt it was given", async () => { + const firstTimelineItem = (await runStream()) + .filter((event) => event.type === "provider_subagent") + .map((event) => event.event) + .find((event) => event.type === "timeline"); + + expect(firstTimelineItem).toMatchObject({ + id: "toolu_01TVF5JXom1yoZVoiaEWAoUV", + item: { type: "user_message", text: "Reply with just the word: banana" }, + }); + }); + + test("keeps a filtered task out of the track even when it emits sidechain frames", async () => { + // A workflow child is refused at declaration, but its frames still carry a parent_tool_use_id. + // Attributing them would recreate exactly what the filter rejected: a descriptor with no + // title and a defaulted "running" status — a nameless row that never finishes. + queryFactory.mockImplementation(() => + buildQueryMock([ + { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, + { + type: "system", + subtype: "task_started", + task_id: "wf-1", + tool_use_id: "toolu_workflow", + task_type: "local_workflow", + workflow_name: "spec", + description: "Run the spec workflow", + }, + { + type: "assistant", + parent_tool_use_id: "toolu_workflow", + message: { model: "claude-opus-5", content: [{ type: "text", text: "working" }] }, + }, + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const events = await collectUntilTerminal(streamSession(session, "run the workflow")); + await session.close(); + + expect(events.filter((event) => event.type === "provider_subagent")).toEqual([]); + }); + + test("labels the parent's Task card with the type and task", async () => { + // Without this the card renders as a bare "Task": the tracker that normally supplies the + // sub_agent detail never runs, because no frame carries parent_tool_use_id. + const card = (await runStream()) + .filter((event) => event.type === "timeline") + .map((event) => event.item) + .find((item) => item.type === "tool_call" && item.detail?.type === "sub_agent"); + + expect(card).toMatchObject({ + type: "tool_call", + callId: "toolu_01TVF5JXom1yoZVoiaEWAoUV", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", + description: "Reply with banana", + }, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts new file mode 100644 index 0000000000..0b7e96f737 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-replay.test.ts @@ -0,0 +1,226 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +/** + * Replay coverage for provider subagents rebuilt from a persisted session. + * + * The live path had tests; this path did not, which is how its derivation drifted from the live + * one. These exercise the real on-disk layout Claude Code writes. + */ + +const TOOL_USE_ID = "toolu_01DgLoPMW9"; +const AGENT_ID = "a1730a6215e1f5cf6"; + +function parentEntry(content: unknown): string { + return JSON.stringify({ + type: "assistant", + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:47.034Z", + message: { role: "assistant", content }, + }); +} + +function taskToolUse(): string { + return parentEntry([ + { + type: "tool_use", + id: TOOL_USE_ID, + name: "Task", + input: { subagent_type: "general-purpose", description: "Summarize the docs" }, + }, + ]); +} + +function taskToolResult(options: { isError?: boolean; mentionAgentId?: boolean } = {}): string { + return JSON.stringify({ + type: "user", + sessionId: "replay-session", + timestamp: "2026-07-26T06:28:00.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: TOOL_USE_ID, + content: options.mentionAgentId === false ? "done" : `agentId: ${AGENT_ID}\ndone`, + is_error: options.isError === true, + }, + ], + }, + }); +} + +function sidechainEntry(): string { + return JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: AGENT_ID, + sessionId: "replay-session", + timestamp: "2026-07-26T06:27:50.000Z", + message: { role: "assistant", content: [{ type: "text", text: "summary of the docs" }] }, + }); +} + +describe("ClaudeAgentSession persisted subagent replay", () => { + const logger = createTestLogger(); + const queryFactory = vi.fn(() => { + throw new Error("replay must not start a query"); + }); + let tempRoot: string; + let cwd: string; + let configDir: string; + + function writeSession(options: { + parentLines: string[]; + meta?: string | null; + sidechainLines?: string[]; + }): void { + const historyDir = claudeProjectDirSync(cwd, { configDir }); + mkdirSync(historyDir, { recursive: true }); + writeFileSync(path.join(historyDir, "replay-session.jsonl"), options.parentLines.join("\n")); + + const subagentDir = path.join(historyDir, "replay-session", "subagents"); + mkdirSync(subagentDir, { recursive: true }); + writeFileSync( + path.join(subagentDir, `agent-${AGENT_ID}.jsonl`), + (options.sidechainLines ?? [sidechainEntry()]).join("\n"), + ); + if (options.meta !== null && options.meta !== undefined) { + writeFileSync(path.join(subagentDir, `agent-${AGENT_ID}.meta.json`), options.meta); + } + } + + async function replayDescriptors(): Promise< + Extract[] + > { + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveVersion: async () => "2.1.220", + }); + const session = await client.resumeSession( + { provider: "claude", sessionId: "replay-session" }, + { cwd }, + ); + const events: Extract[] = []; + for await (const event of session.streamHistory()) { + if (event.type === "provider_subagent") events.push(event); + } + await session.close(); + return events; + } + + function upserts(events: Extract[]) { + return events.map((e) => e.event).filter((e) => e.type === "upsert"); + } + + beforeEach(() => { + tempRoot = mkdtempSync(path.join(os.tmpdir(), "claude-subagent-replay-")); + cwd = path.join(tempRoot, "repo"); + configDir = path.join(tempRoot, "claude-config"); + mkdirSync(cwd, { recursive: true }); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + rmSync(tempRoot, { recursive: true, force: true }); + }); + + test("links a subagent to its Task call through the meta sidecar", async () => { + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ + agentType: "general-purpose", + description: "Summarize the docs", + toolUseId: TOOL_USE_ID, + spawnDepth: 1, + }), + }); + + const declared = upserts(await replayDescriptors())[0]; + expect(declared).toMatchObject({ + id: TOOL_USE_ID, + toolCallId: TOOL_USE_ID, + title: "general-purpose", + description: "Summarize the docs", + }); + }); + + test("links through the meta sidecar even when the parent's tool_result is missing", async () => { + // The regression the sidecar exists to fix: with no tool_result there is nothing to scrape, + // and the subagent used to degrade to a generic title with no tool call attached. + writeSession({ + parentLines: [taskToolUse()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID, agentType: "Explore" }), + }); + + const declared = upserts(await replayDescriptors())[0]; + expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID }); + expect(declared).not.toMatchObject({ title: "Claude subagent" }); + }); + + test("falls back to the legacy scrape when no meta sidecar exists", async () => { + writeSession({ parentLines: [taskToolUse(), taskToolResult()], meta: null }); + + const events = upserts(await replayDescriptors()); + expect(events[0]).toMatchObject({ id: TOOL_USE_ID, title: "general-purpose" }); + expect(events.at(-1)).toMatchObject({ status: "completed" }); + }); + + test("carries a failed outcome through to the descriptor", async () => { + writeSession({ + parentLines: [taskToolUse(), taskToolResult({ isError: true })], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + }); + + expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "failed" }); + }); + + test("leaves a subagent running when the transcript records no outcome", async () => { + writeSession({ + parentLines: [taskToolUse()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + }); + + // Asserted as "nothing terminal" rather than "every upsert says running": a subtitle-only + // upsert carries no status, so presentation arriving after completion cannot revert a child. + const statuses = upserts(await replayDescriptors()).map((event) => event.status); + expect(statuses).not.toContain("completed"); + expect(statuses).not.toContain("failed"); + expect(statuses).toContain("running"); + }); + + test.each([ + ["truncated json", '{"toolUseId":"toolu_01DgLoPMW9"'], + ["not json", "toolUseId: toolu"], + ["empty file", ""], + ["a json array", "[1,2,3]"], + ])("survives a malformed meta sidecar (%s)", async (_label, contents) => { + writeSession({ parentLines: [taskToolUse(), taskToolResult()], meta: contents }); + + // Falls back to the scrape rather than losing the subagent. + const events = upserts(await replayDescriptors()); + expect(events[0]).toMatchObject({ id: TOOL_USE_ID }); + }); + + test("replays the subagent's own transcript onto its timeline", async () => { + writeSession({ + parentLines: [taskToolUse(), taskToolResult()], + meta: JSON.stringify({ toolUseId: TOOL_USE_ID }), + }); + + const timeline = (await replayDescriptors()) + .map((event) => event.event) + .filter((event) => event.type === "timeline"); + expect(timeline.length).toBeGreaterThan(0); + expect(timeline[0]).toMatchObject({ id: TOOL_USE_ID }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts new file mode 100644 index 0000000000..d4fc0bcc0c --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.subagent-interrupt.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { ProviderSubagentInputEvent } from "../../provider-subagents/store.js"; +import { ClaudeAgentClient } from "./agent.js"; + +/** + * A subagent's lifecycle when its turn is interrupted. + * + * Task ids are session-scoped, so a turn ending must not forget them: a backgrounded child settles + * after the interrupt and still needs the descriptor it was declared on. What a turn ending does + * warrant is terminalizing the children that were running in the foreground, which died with it. + */ + +const queryFactory = vi.fn(); + +/** + * A query the test feeds one message at a time and never ends, so a turn can be interrupted while + * the session stays open and keeps streaming — the shape of "background the child, interrupt, keep + * working" that the whole feature exists for. + */ +function buildOpenQueryMock() { + const pending: unknown[] = []; + let wake: (() => void) | null = null; + let finished = false; + + const settle = () => { + const resume = wake; + wake = null; + resume?.(); + }; + + const query = { + next: vi.fn(async () => { + for (;;) { + if (pending.length > 0) return { done: false, value: pending.shift() }; + if (finished) return { done: true, value: undefined }; + await new Promise((resolve) => { + wake = resolve; + }); + } + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => { + finished = true; + settle(); + return undefined; + }), + close: vi.fn(() => { + finished = true; + settle(); + }), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => []), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, + }; + + return { + query, + push(message: unknown) { + pending.push(message); + settle(); + }, + }; +} + +const TASK_ID = "aa482d02957fe96c8"; +const SUBAGENT_ID = "toolu_01TVF5JXom1yoZVoiaEWAoUV"; +const SECOND_SUBAGENT_ID = "toolu_01SyncMarker"; + +describe("subagent lifecycle across an interrupt", () => { + afterEach(() => { + queryFactory.mockReset(); + }); + + async function startInterruptibleTurn() { + const channel = buildOpenQueryMock(); + queryFactory.mockImplementation(() => channel.query); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + + const subagentEvents: ProviderSubagentInputEvent[] = []; + session.subscribe((event) => { + if (event.type === "provider_subagent") subagentEvents.push(event.event); + }); + + await session.startTurn("delegate work"); + channel.push({ + type: "system", + subtype: "init", + session_id: "bg-session", + permissionMode: "default", + }); + channel.push({ + type: "system", + subtype: "task_started", + task_id: TASK_ID, + tool_use_id: SUBAGENT_ID, + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Reply with banana", + }); + await vi.waitFor(() => expect(subagentEvents.length).toBeGreaterThan(0)); + + return { channel, session, subagentEvents }; + } + + function statuses(events: ProviderSubagentInputEvent[], id: string): string[] { + return events.flatMap((event) => + event.type === "upsert" && event.id === id && event.status ? [event.status] : [], + ); + } + + test("a backgrounded child settles after the turn that spawned it is canceled", async () => { + const { channel, session, subagentEvents } = await startInterruptibleTurn(); + + channel.push({ + type: "system", + subtype: "task_updated", + task_id: TASK_ID, + patch: { is_backgrounded: true }, + }); + // Backgrounding is a silent patch — nothing observable follows from it alone. The stream is + // ordered, so declaring a second subagent behind it and waiting for that declaration proves + // the patch was consumed. + channel.push({ + type: "system", + subtype: "task_started", + task_id: "second-task", + tool_use_id: SECOND_SUBAGENT_ID, + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Sync marker", + }); + await vi.waitFor(() => + expect(subagentEvents).toContainEqual(expect.objectContaining({ id: SECOND_SUBAGENT_ID })), + ); + + await session.interrupt(); + + // The child was told to outlive the turn, so cancellation says nothing about it... + channel.push({ + type: "system", + subtype: "task_notification", + task_id: TASK_ID, + tool_use_id: SUBAGENT_ID, + status: "completed", + usage: { total_tokens: 32271, tool_uses: 2, duration_ms: 10934 }, + }); + + // ...and its completion still routes, because the interrupt did not wipe the task table. + await vi.waitFor(() => + expect(subagentEvents).toContainEqual( + expect.objectContaining({ id: SUBAGENT_ID, status: "completed" }), + ), + ); + expect(statuses(subagentEvents, SUBAGENT_ID)).toEqual(["running", "completed"]); + // The sibling stayed in the foreground, so it died with the turn. + expect(statuses(subagentEvents, SECOND_SUBAGENT_ID)).toEqual(["running", "canceled"]); + + await session.close(); + }); + + test("a foreground child is canceled with the turn that spawned it", async () => { + const { session, subagentEvents } = await startInterruptibleTurn(); + + await session.interrupt(); + + await vi.waitFor(() => + expect(statuses(subagentEvents, SUBAGENT_ID)).toEqual(["running", "canceled"]), + ); + + await session.close(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 463540825d..c29f7ca27e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -41,7 +41,19 @@ import { resolveClaudeDisabledThinkingForModel, } from "./model-manifest.js"; import { parsePartialJsonObject } from "./partial-json.js"; +import { mergeClaudeHooks } from "./hooks.js"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; +import { + ClaudeTaskProtocolSource, + type ClaudeHookObservationInput, +} from "./subagents/live-source.js"; +import { + observeReplaySubagents, + parseClaudeSubagentMeta, + type ClaudeReplayParentFacts, + type ClaudeSubagentMeta, +} from "./subagents/replay-source.js"; +import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js"; import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js"; import { buildBinaryDiagnosticRows, @@ -1983,8 +1995,15 @@ class ClaudeAgentSession implements AgentSession { private autonomousTurn: AutonomousTurnState | null = null; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly timelineAssembler = new TimelineAssembler(); + private readonly taskProtocolSource = new ClaudeTaskProtocolSource({ + getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, + }); private readonly sidechainTracker = new ClaudeSidechainTracker({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, + // Releases that predate the task protocol announce nothing, so the tracker keeps deriving + // identity and status from frames for them. Detecting the capability beats comparing version + // strings: it reacts to what this session actually does. + isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive, }); private persistedHistory: PersistedTimelineEntry[] = []; private persistedProviderSubagentEvents: Extract< @@ -2469,6 +2488,7 @@ class ClaudeAgentSession implements AgentSession { this.cancelCurrentTurn = null; this.turnState = "idle"; this.sidechainTracker.clear(); + this.taskProtocolSource.reset(); this.input?.end(); this.query?.close?.(); await this.awaitWithTimeout(this.query?.interrupt?.(), "close query interrupt"); @@ -3083,6 +3103,10 @@ class ClaudeAgentSession implements AgentSession { ...(effort ? { effort } : {}), ...extraClaudeOptions, ...settingsOptions, + // Merged rather than assigned above: extraClaudeOptions is spread after the base, so any + // user-configured hooks would otherwise replace these wholesale and silently stop effort + // from being observed. + hooks: mergeClaudeHooks(this.buildSubagentEffortHooks(), extraClaudeOptions?.hooks), ...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }), env: sdkEnv, }; @@ -3712,10 +3736,23 @@ class ClaudeAgentSession implements AgentSession { ): AgentStreamEvent[] { const parentToolUseId = readClaudeParentToolUseId(message); if (parentToolUseId) { - return this.sidechainTracker.handleMessage(message, parentToolUseId); + return this.translateSidechainFrameToEvents(message, parentToolUseId); } const events: AgentStreamEvent[] = []; + + // Subagent identity and lifecycle are announced by Claude Code's task protocol, so they are + // read rather than inferred from sidechain frames. `task_started` precedes the child's first + // frame, so the descriptor exists before any timeline item lands on it. + const subagentObservations = this.taskProtocolSource.observe(message); + for (const event of foldSubagentObservations(subagentObservations)) { + events.push({ type: "provider_subagent", provider: "claude", event }); + } + for (const observation of subagentObservations) { + if (observation.kind !== "declared") continue; + const card = this.buildSubagentToolCallCard(observation); + if (card) events.push(card); + } if (message.type !== "system") { const sessionCapture = this.captureSessionIdFromMessage(message); if (sessionCapture.notice) { @@ -3766,6 +3803,67 @@ class ClaudeAgentSession implements AgentSession { return events; } + /** + * A frame from inside a subagent, routed to that child rather than the parent's transcript. + */ + private translateSidechainFrameToEvents( + message: SDKMessage, + parentToolUseId: string, + ): AgentStreamEvent[] { + // Once a CLI announces its tasks it announces all of them, so a frame for one that was never + // declared is work the filter already rejected — a workflow child, ambient housekeeping, a + // grandchild announced in someone else's session. Attributing it anyway materializes exactly + // what the filter prevents: a nameless descriptor stuck running, plus a timeline no surface + // can open, both held for the session's lifetime. + if ( + this.taskProtocolSource.announcesTasks && + !this.taskProtocolSource.isDeclared(parentToolUseId) + ) { + return []; + } + // The child's own frames are the only place its model appears; the task protocol does not + // announce it. Read per frame rather than snapshotting, since a provider can swap models + // mid-flight on overload or refusal fallback. + const runtimeEvents = foldSubagentObservations( + this.taskProtocolSource.observeSidechainFrame(message, parentToolUseId), + ).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event })); + return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, parentToolUseId)]; + } + + /** + * The Task card in the parent transcript, built from the declaration. + * + * The sidechain tracker also emits this card, enriched with the child's action log, and the two + * collapse on the shared tool-call id. Emitting it at declaration matters because a + * backgrounded subagent produces no sidechain frames at all — verified on the wire — so the + * tracker never runs for one and its card would otherwise stay an unlabeled "Task". + */ + private buildSubagentToolCallCard( + declaration: Extract, + ): AgentStreamEvent | null { + const toolCall = mapClaudeRunningToolCall({ + name: "Task", + callId: declaration.id, + input: null, + output: null, + }); + if (!toolCall) return null; + return { + type: "timeline", + provider: "claude", + item: { + ...toolCall, + detail: { + type: "sub_agent", + ...(declaration.title ? { subAgentType: declaration.title } : {}), + ...(declaration.description ? { description: declaration.description } : {}), + log: "", + actions: [], + }, + }, + }; + } + private appendSidechainResultEvents(message: SDKMessage, events: AgentStreamEvent[]): void { const content = toObjectRecord(toObjectRecord(message)?.message)?.content; if (!Array.isArray(content)) return; @@ -4251,6 +4349,15 @@ class ClaudeAgentSession implements AgentSession { } this.toolUseCache.clear(); this.sidechainTracker.clear(); + // The task protocol's routing table is session-scoped, so it is deliberately NOT reset here: + // the turn ended, the session did not. Wiping it would strand every task id the session still + // holds — a backgrounded child that settles after the interrupt would find no descriptor to + // land on, and ownership would flip back to the legacy tracker mid-session. + for (const event of foldSubagentObservations( + this.taskProtocolSource.cancelRunningForegroundTasks(), + )) { + this.pushEvent({ type: "provider_subagent", provider: "claude", event }); + } } private pushToolCall( @@ -4271,6 +4378,40 @@ class ClaudeAgentSession implements AgentSession { this.notifySubscribers(event); } + /** + * Effort is reachable only through hooks. + * + * It appears nowhere on the message stream — verified by scanning every message type at depth + * — and the level Paseo requests is not necessarily the level that runs, because a model that + * does not support it is silently downgraded. A hook firing inside a subagent reports the + * active post-downgrade level alongside the subagent's `agent_id`. + * + * These are observation-only: they record what they see and always return an empty result, so + * they can never alter tool execution or turn control. + */ + private buildSubagentEffortHooks(): NonNullable { + const observe = async (input: unknown): Promise> => { + try { + for (const event of foldSubagentObservations( + this.taskProtocolSource.observeHook(input as ClaudeHookObservationInput), + )) { + this.notifySubscribers({ type: "provider_subagent", provider: "claude", event }); + } + } catch (error) { + this.logger.debug({ err: error }, "Failed to read subagent effort from hook"); + } + return {}; + }; + + // SubagentStart carries no effort (documented as absent for lifecycle hooks), so the value + // lands on the child's first tool use. SubagentStop covers a child that used no tools. + return { + PreToolUse: [{ hooks: [observe] }], + PostToolUse: [{ hooks: [observe] }], + SubagentStop: [{ hooks: [observe] }], + }; + } + private notifySubscribers(event: AgentStreamEvent): void { const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id; const tagged = turnId ? { ...event, turnId } : event; @@ -4341,19 +4482,39 @@ class ClaudeAgentSession implements AgentSession { } } - private ingestPersistedSidechains(parentContent: string, sidechainContents: string[]): void { + private ingestPersistedSidechains( + parentContent: string, + sidechains: ClaudeSidechainHistory, + ): void { const parentEntries = parseClaudeHistoryRecords(parentContent).filter( (entry) => entry.isSidechain !== true, ); - const sidechainEntries = [parentContent, ...sidechainContents] + const sidechainEntries = [parentContent, ...sidechains.contents] .flatMap(parseClaudeHistoryRecords) .filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string"); if (sidechainEntries.length === 0) { return; } + + // Replay produces the same observations the live task protocol produces, then folds them + // with the same function, so identity and status are derived once for both paths. + const observations = observeReplaySubagents({ + subagents: [...groupClaudeSidechainEntries(sidechainEntries)].map(([agentId, entries]) => ({ + agentId, + meta: sidechains.metaByAgentId.get(agentId) ?? null, + entries, + })), + parent: readClaudeReplayParentFacts(parentEntries), + convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry), + }); + this.persistedProviderSubagentEvents.push( - ...buildClaudePersistedSidechainEvents(parentEntries, sidechainEntries, (entry) => - this.convertHistoryEntry(entry), + ...foldSubagentObservations(observations).map( + (event): Extract => ({ + type: "provider_subagent", + provider: "claude", + event, + }), ), ); this.historyPending = true; @@ -5132,15 +5293,58 @@ function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] { return entries; } -function readClaudeSidechainHistory(historyPath: string): string[] { +/** + * Collect what the parent transcript knows about its Task calls, in the shape the shared replay + * source consumes. The agentId scrape is kept only as a fallback for sessions recorded before + * Claude Code wrote the meta sidecar. + */ +function readClaudeReplayParentFacts(parentEntries: ClaudeHistoryEntry[]): ClaudeReplayParentFacts { + const toolCalls = new Map(); + for (const [id, call] of readClaudeHistoricalSubagentToolCalls(parentEntries)) { + toolCalls.set(id, { + ...((call.name ?? call.subagentType) ? { title: call.name ?? call.subagentType } : {}), + ...(call.description ? { description: call.description } : {}), + }); + } + + // Read outcomes straight off the tool_result blocks rather than reusing the agentId scrape, + // so a subagent linked through its meta sidecar still gets a status when the scrape missed it. + const outcomesByToolCallId = new Map(); + for (const entry of parentEntries) { + const content = toObjectRecord(entry.message)?.content; + if (!Array.isArray(content)) continue; + for (const value of content) { + const block = toObjectRecord(value); + if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; + if (!toolCalls.has(block.tool_use_id)) continue; + outcomesByToolCallId.set(block.tool_use_id, { failed: block.is_error === true }); + } + } + + return { + toolCalls, + linksByAgentId: readClaudeHistoricalSubagentToolResults(parentEntries), + outcomesByToolCallId, + }; +} + +interface ClaudeSidechainHistory { + contents: string[]; + /** agentId -> sidecar metadata, when Claude Code wrote one next to the transcript. */ + metaByAgentId: Map; +} + +const CLAUDE_SUBAGENT_META_FILE = /^agent-(.+)\.meta\.json$/; + +function readClaudeSidechainHistory(historyPath: string): ClaudeSidechainHistory { const sessionDirectory = path.join( path.dirname(historyPath), path.basename(historyPath, ".jsonl"), ); const sidechainDirectory = path.join(sessionDirectory, "subagents"); - if (!fs.existsSync(sidechainDirectory)) return []; + const history: ClaudeSidechainHistory = { contents: [], metaByAgentId: new Map() }; + if (!fs.existsSync(sidechainDirectory)) return history; - const contents: string[] = []; const directories = [sidechainDirectory]; while (directories.length > 0) { const directory = directories.pop(); @@ -5149,12 +5353,26 @@ function readClaudeSidechainHistory(historyPath: string): string[] { const entryPath = path.join(directory, entry.name); if (entry.isDirectory()) { directories.push(entryPath); - } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { - contents.push(fs.readFileSync(entryPath, "utf8")); + continue; + } + if (!entry.isFile()) continue; + if (entry.name.endsWith(".jsonl")) { + history.contents.push(fs.readFileSync(entryPath, "utf8")); + continue; + } + // The sidecar carries the Task tool_use id, which is the same id the live stream keys on. + // Reading it is what lets replay and live agree instead of each deriving its own link. + const metaMatch = CLAUDE_SUBAGENT_META_FILE.exec(entry.name); + if (!metaMatch?.[1]) continue; + try { + const meta = parseClaudeSubagentMeta(fs.readFileSync(entryPath, "utf8")); + if (meta) history.metaByAgentId.set(metaMatch[1], meta); + } catch { + // Undocumented internals: a missing or unreadable sidecar must never fail ingestion. } } } - return contents; + return history; } interface ClaudeHistoricalSubagentToolCall { @@ -5224,89 +5442,6 @@ function groupClaudeSidechainEntries( return entriesByAgentId; } -function buildClaudePersistedSidechainEvents( - parentEntries: ClaudeHistoryEntry[], - sidechainEntries: ClaudeHistoryEntry[], - convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[], -): Extract[] { - const events: Extract[] = []; - const toolCalls = readClaudeHistoricalSubagentToolCalls(parentEntries); - const toolResults = readClaudeHistoricalSubagentToolResults(parentEntries); - for (const [agentId, entries] of groupClaudeSidechainEntries(sidechainEntries)) { - events.push( - ...buildClaudePersistedSidechainAgentEvents( - agentId, - entries, - toolCalls, - toolResults, - convertEntry, - ), - ); - } - return events; -} - -function resolveClaudeHistoricalSubagentTitle( - toolCall: ClaudeHistoricalSubagentToolCall | undefined, -): string { - return toolCall?.name ?? toolCall?.subagentType ?? "Claude subagent"; -} - -function buildClaudePersistedSidechainAgentEvents( - agentId: string, - entries: ClaudeHistoryEntry[], - toolCalls: ReadonlyMap, - toolResults: ReadonlyMap, - convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[], -): Extract[] { - const result = toolResults.get(agentId); - const id = result?.toolCallId ?? agentId; - const toolCall = result ? toolCalls.get(result.toolCallId) : undefined; - const firstTimestamp = normalizeProviderReplayTimestamp(entries[0]?.timestamp); - const events: Extract[] = [ - { - type: "provider_subagent", - provider: "claude", - event: { - type: "upsert", - id, - title: resolveClaudeHistoricalSubagentTitle(toolCall), - description: toolCall?.description ?? null, - status: "running", - toolCallId: result?.toolCallId ?? null, - ...(firstTimestamp ? { timestamp: firstTimestamp } : {}), - }, - }, - ]; - for (const entry of entries) { - const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); - for (const item of convertEntry(entry)) { - events.push({ - type: "provider_subagent", - provider: "claude", - event: { - type: "timeline", - id, - item, - ...(timestamp ? { timestamp } : {}), - }, - }); - } - } - const lastTimestamp = normalizeProviderReplayTimestamp(entries.at(-1)?.timestamp); - events.push({ - type: "provider_subagent", - provider: "claude", - event: { - type: "upsert", - id, - status: result?.failed ? "failed" : "completed", - ...(lastTimestamp ? { timestamp: lastTimestamp } : {}), - }, - }); - return events; -} - interface ClaudeHistoryEntry { type?: unknown; subtype?: unknown; diff --git a/packages/server/src/server/agent/providers/claude/hooks.test.ts b/packages/server/src/server/agent/providers/claude/hooks.test.ts new file mode 100644 index 0000000000..9bb4915afb --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/hooks.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; + +import { mergeClaudeHooks } from "./hooks.js"; + +describe("mergeClaudeHooks", () => { + const own = { PreToolUse: [{ hooks: [vi.fn()] }] } as never; + + it("keeps Paseo's hooks when the user configured none", () => { + expect(mergeClaudeHooks(own, undefined)).toEqual(own); + expect(mergeClaudeHooks(own, null)).toEqual(own); + }); + + it("appends user hooks for the same event instead of replacing them", () => { + const userHook = vi.fn(); + const merged = mergeClaudeHooks(own, { PreToolUse: [{ hooks: [userHook] }] }) as { + PreToolUse: unknown[]; + }; + // Both must survive: dropping Paseo's would silently stop effort being observed, and + // dropping the user's would break their configuration. + expect(merged.PreToolUse).toHaveLength(2); + }); + + it("carries through events Paseo does not register", () => { + const merged = mergeClaudeHooks(own, { SessionStart: [{ hooks: [vi.fn()] }] }) as Record< + string, + unknown[] + >; + expect(merged.SessionStart).toHaveLength(1); + expect(merged.PreToolUse).toHaveLength(1); + }); + + it("ignores malformed entries rather than throwing", () => { + expect(mergeClaudeHooks(own, { PreToolUse: "not-an-array" })).toEqual(own); + expect(mergeClaudeHooks(own, "nonsense")).toEqual(own); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/hooks.ts b/packages/server/src/server/agent/providers/claude/hooks.ts new file mode 100644 index 0000000000..5ccc2f3441 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/hooks.ts @@ -0,0 +1,21 @@ +import type { ClaudeOptions } from "./query.js"; + +type ClaudeHooks = NonNullable; + +/** + * Combine Paseo's observation hooks with any the user configured, per event. + * + * Neither side wins: Claude Code runs every matcher registered for an event, so appending keeps + * a user's hooks working while Paseo keeps observing. Assigning either one would silently drop + * the other, and the failure would be invisible — effort would simply stop appearing. + */ +export function mergeClaudeHooks(own: ClaudeHooks, extra: unknown): ClaudeHooks { + if (!extra || typeof extra !== "object") return own; + const merged: ClaudeHooks = { ...own }; + for (const [event, matchers] of Object.entries(extra as Record)) { + if (!Array.isArray(matchers)) continue; + const key = event as keyof ClaudeHooks; + merged[key] = [...(merged[key] ?? []), ...matchers] as ClaudeHooks[typeof key]; + } + return merged; +} diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 48bf900884..51601ecb9f 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -132,3 +132,32 @@ function isRecord(value: unknown): value is Record { export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null { return normalizeClaudeManifestRuntimeModelId(value); } + +/** + * Placeholder model values Claude Code writes on frames with no real inference behind them. + * These are not models and must never be displayed. + */ +const CLAUDE_PLACEHOLDER_MODEL_IDS = new Set([""]); + +/** + * Resolve a model id observed on a Claude assistant frame, for display. + * + * Prefers the manifest-normalized id so equivalent spellings collapse (a dated alias and a + * gateway prefix are the same model), but falls back to the raw string when the manifest does + * not know it. The fallback matters: Claude Code is an Anthropic-compatible client, so subagents + * routinely report models that are not Anthropic's — Z.AI GLM ids via `ANTHROPIC_BASE_URL` + * (docs/custom-providers.md) among them. Manifest-only resolution would blank the model for + * exactly those users. + * + * Note a `[1m]` suffix is preserved where it names its own manifest entry: a 1M-context variant + * is a distinct model with a distinct context window, not a spelling of the 200K one. + * + * Returns null for placeholders and empty values, meaning "not observed". + */ +export function resolveObservedClaudeModelId(value: string | null | undefined): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed || CLAUDE_PLACEHOLDER_MODEL_IDS.has(trimmed)) { + return null; + } + return normalizeClaudeManifestRuntimeModelId(trimmed) ?? trimmed; +} diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index d5257e3d1c..131d8ee12b 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -58,9 +58,19 @@ function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk { export class ClaudeSidechainTracker { private readonly activeSidechains = new Map(); private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined; + private readonly isDescriptorOwnedElsewhere: () => boolean; - constructor(input: { getToolInput: (toolUseId: string) => AgentMetadata | null | undefined }) { + constructor(input: { + getToolInput: (toolUseId: string) => AgentMetadata | null | undefined; + /** + * When the provider learns subagent identity and status declaratively, this tracker stops + * emitting descriptor upserts and maps frames to timeline items only. Two owners writing the + * same descriptor from different evidence is how the live and replay paths drifted apart. + */ + isDescriptorOwnedElsewhere?: () => boolean; + }) { this.getToolInput = input.getToolInput; + this.isDescriptorOwnedElsewhere = input.isDescriptorOwnedElsewhere ?? (() => false); } handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] { @@ -124,19 +134,25 @@ export class ClaudeSidechainTracker { actions: [], }; + const descriptorEvents: AgentStreamEvent[] = this.isDescriptorOwnedElsewhere() + ? [] + : [ + { + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id: parentToolUseId, + title: state.name ?? state.subAgentType ?? "Claude subagent", + description: state.description ?? null, + status: "running", + toolCallId: parentToolUseId, + }, + }, + ]; + return [ - { - type: "provider_subagent", - provider: "claude", - event: { - type: "upsert", - id: parentToolUseId, - title: state.name ?? state.subAgentType ?? "Claude subagent", - description: state.description ?? null, - status: "running", - toolCallId: parentToolUseId, - }, - }, + ...descriptorEvents, ...childTimelineItems.map( (item): AgentStreamEvent => ({ type: "provider_subagent", @@ -157,6 +173,10 @@ export class ClaudeSidechainTracker { finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] { const events: AgentStreamEvent[] = []; + if (this.isDescriptorOwnedElsewhere()) { + this.activeSidechains.clear(); + return events; + } for (const [id, state] of this.activeSidechains) { events.push({ type: "provider_subagent", @@ -179,6 +199,7 @@ export class ClaudeSidechainTracker { const state = this.activeSidechains.get(id); if (!state) return []; this.activeSidechains.delete(id); + if (this.isDescriptorOwnedElsewhere()) return []; return [ { type: "provider_subagent", diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts new file mode 100644 index 0000000000..65b118e983 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.test.ts @@ -0,0 +1,489 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { describe, expect, it } from "vitest"; + +import { ClaudeTaskProtocolSource } from "./live-source.js"; + +// Shapes copied from real wire output (Claude Code 2.1.220) rather than the type declarations, +// so the fixtures stay honest about what actually arrives. +function taskStarted(overrides: Record = {}): SDKMessage { + return { + type: "system", + subtype: "task_started", + task_id: "a1730a6215e1f5cf6", + tool_use_id: "toolu_01DgLoPMW9", + description: "Summarize hover and unistyles docs", + subagent_type: "general-purpose", + task_type: "local_agent", + ...overrides, + } as unknown as SDKMessage; +} + +function taskUpdated(status: string, taskId = "a1730a6215e1f5cf6"): SDKMessage { + return { + type: "system", + subtype: "task_updated", + task_id: taskId, + patch: { status, end_time: 1785117455676 }, + } as unknown as SDKMessage; +} + +function taskNotification(status: string, taskId = "a1730a6215e1f5cf6"): SDKMessage { + return { + type: "system", + subtype: "task_notification", + task_id: taskId, + tool_use_id: "toolu_01DgLoPMW9", + status, + output_file: "/tmp/x.output", + summary: "done", + } as unknown as SDKMessage; +} + +describe("ClaudeTaskProtocolSource", () => { + it("declares a subagent from its announcement, keyed by the Task tool_use id", () => { + const source = new ClaudeTaskProtocolSource(); + + expect(source.observe(taskStarted())).toEqual([ + { + kind: "declared", + id: "toolu_01DgLoPMW9", + toolCallId: "toolu_01DgLoPMW9", + title: "general-purpose", + description: "Summarize hover and unistyles docs", + }, + ]); + }); + + it("prefers the name the Task call gave over the agent type", () => { + // The replay source titles a subagent `input.name ?? subagent_type`. Live reads the same + // field from the same Task call, so one subagent is named identically on both paths. + const source = new ClaudeTaskProtocolSource({ + getToolInput: (toolUseId) => + toolUseId === "toolu_01DgLoPMW9" ? { name: "docs-scout", subagent_type: "Explore" } : null, + }); + + expect(source.observe(taskStarted({ subagent_type: "Explore" }))[0]).toMatchObject({ + kind: "declared", + title: "docs-scout", + }); + }); + + it("falls back to the agent type when the Task call named nothing", () => { + const source = new ClaudeTaskProtocolSource({ getToolInput: () => ({ description: "x" }) }); + expect(source.observe(taskStarted())[0]).toMatchObject({ title: "general-purpose" }); + }); + + it("routes a status update through the task_id it learned at declaration", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + + // task_updated carries no tool_use_id — the mapping is the only way to route it. + expect(source.observe(taskUpdated("completed"))).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "completed" }, + ]); + }); + + it("does not re-announce a status that already holds", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.observe(taskUpdated("completed")); + + // task_updated and task_notification arrive in the same millisecond on the real wire. + expect(source.observe(taskNotification("completed"))).toEqual([]); + }); + + it("treats a killed or stopped task as canceled, not failed", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + expect(source.observe(taskUpdated("killed"))).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "canceled" }, + ]); + }); + + it("keeps a paused task running, since the descriptor has no paused state", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.observe(taskUpdated("completed")); + expect(source.observe(taskUpdated("paused"))).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "running" }, + ]); + }); + + it("ignores tasks that carry no tool_use id", () => { + const source = new ClaudeTaskProtocolSource(); + expect(source.observe(taskStarted({ tool_use_id: undefined }))).toEqual([]); + expect(source.isActive).toBe(false); + }); + + it("ignores a backgrounded shell command", () => { + const source = new ClaudeTaskProtocolSource(); + // Real wire shape: a backgrounded Bash announces with a tool_use_id but no subagent_type, + // so filtering on the id alone would put `sleep 20` in the subagents track. + expect( + source.observe( + taskStarted({ + task_id: "b51skux0z", + tool_use_id: "toolu_01MgVdcGPYnqE8cJQuccFtkU", + task_type: "local_bash", + subagent_type: undefined, + description: "Sleep 20 seconds in background", + }), + ), + ).toEqual([]); + expect(source.isActive).toBe(false); + }); + + it("ignores a workflow run", () => { + const source = new ClaudeTaskProtocolSource(); + expect( + source.observe(taskStarted({ task_type: "local_workflow", subagent_type: undefined })), + ).toEqual([]); + }); + + it("accepts a subagent from a release that predates task_type", () => { + const source = new ClaudeTaskProtocolSource(); + const observations = source.observe(taskStarted({ task_type: undefined })); + expect(observations[0]).toMatchObject({ kind: "declared", title: "general-purpose" }); + }); + + it("ignores an untyped task with no subagent type", () => { + const source = new ClaudeTaskProtocolSource(); + expect(source.observe(taskStarted({ task_type: undefined, subagent_type: undefined }))).toEqual( + [], + ); + }); + + it("opens the child timeline with the prompt it was given", () => { + const source = new ClaudeTaskProtocolSource(); + const observations = source.observe( + taskStarted({ prompt: "Reply with just the word: banana" }), + ); + + expect(observations[1]).toEqual({ + kind: "timeline", + id: "toolu_01DgLoPMW9", + item: { type: "user_message", text: "Reply with just the word: banana" }, + }); + }); + + it("declares without a timeline item when no prompt was announced", () => { + const source = new ClaudeTaskProtocolSource(); + expect(source.observe(taskStarted())).toHaveLength(1); + }); + + it("ignores ambient housekeeping tasks", () => { + const source = new ClaudeTaskProtocolSource(); + expect(source.observe(taskStarted({ skip_transcript: true }))).toEqual([]); + expect(source.isActive).toBe(false); + }); + + it("routes concurrent subagents independently", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.observe( + taskStarted({ + task_id: "a88cdd22e38fd1dcf", + tool_use_id: "toolu_01GMd4ob4m", + description: "Summarize forms and design docs", + }), + ); + + expect(source.observe(taskUpdated("completed", "a88cdd22e38fd1dcf"))).toEqual([ + { kind: "status", id: "toolu_01GMd4ob4m", status: "completed" }, + ]); + expect(source.observe(taskUpdated("failed", "a1730a6215e1f5cf6"))).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "failed" }, + ]); + }); + + it("drops a status for a task it never saw declared", () => { + const source = new ClaudeTaskProtocolSource(); + expect(source.observe(taskUpdated("completed", "unknown-task"))).toEqual([]); + }); + + it("does not readmit a filtered task through its notification", () => { + const source = new ClaudeTaskProtocolSource(); + // A backgrounded shell is filtered at declaration, but it still gets a task_notification + // carrying a tool_use_id. Routing status off that id would recreate the descriptor with a + // status and no identity — a nameless row in the subagents track. + source.observe( + taskStarted({ + task_id: "b51skux0z", + tool_use_id: "toolu_01MgVdcGPYnqE8cJQuccFtkU", + task_type: "local_bash", + subagent_type: undefined, + }), + ); + + expect(source.observe(taskNotification("completed", "b51skux0z"))).toEqual([]); + }); + + it("drops a notification for a task it never declared", () => { + const source = new ClaudeTaskProtocolSource(); + expect(source.observe(taskNotification("failed", "unknown-task"))).toEqual([]); + }); + + it("reports inactivity until a task is announced, for older CLIs", () => { + const source = new ClaudeTaskProtocolSource(); + expect(source.isActive).toBe(false); + source.observe(taskStarted()); + expect(source.isActive).toBe(true); + source.reset(); + expect(source.isActive).toBe(false); + }); + + it("cancels the foreground subagents a canceled turn was running", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + + expect(source.cancelRunningForegroundTasks()).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "canceled" }, + ]); + // Idempotent: the child is no longer running, so a second cancellation says nothing. + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("leaves an already-settled subagent alone when the turn is canceled", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.observe(taskUpdated("completed")); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("does not cancel a backgrounded subagent, which outlives the turn", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.observe({ + type: "system", + subtype: "task_updated", + task_id: "a1730a6215e1f5cf6", + patch: { is_backgrounded: true }, + } as unknown as SDKMessage); + + expect(source.cancelRunningForegroundTasks()).toEqual([]); + }); + + it("still routes a backgrounded subagent that settles after the interrupt", () => { + // The headline case: interrupt, continue, and the child that was told to outlive the turn + // reports completion later. Wiping the routing table on cancel drops this on the floor and + // leaves the row spinning forever. + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.observe({ + type: "system", + subtype: "task_updated", + task_id: "a1730a6215e1f5cf6", + patch: { is_backgrounded: true }, + } as unknown as SDKMessage); + source.cancelRunningForegroundTasks(); + + expect(source.observe(taskNotification("completed"))).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "completed" }, + ]); + expect(source.isActive).toBe(true); + }); + + it("lets a late notification correct a cancellation it guessed wrong", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.cancelRunningForegroundTasks(); + + expect(source.observe(taskNotification("completed"))).toEqual([ + { kind: "status", id: "toolu_01DgLoPMW9", status: "completed" }, + ]); + }); + + it("ignores unrelated system messages", () => { + const source = new ClaudeTaskProtocolSource(); + expect( + source.observe({ type: "system", subtype: "init", model: "opus" } as unknown as SDKMessage), + ).toEqual([]); + }); +}); + +describe("ClaudeTaskProtocolSource usage and runtime", () => { + function taskProgress(usage: Record, taskId = "a1730a6215e1f5cf6"): SDKMessage { + return { + type: "system", + subtype: "task_progress", + task_id: taskId, + usage, + last_tool_name: "Read", + } as unknown as SDKMessage; + } + + function assistantFrame(model: string): SDKMessage { + return { type: "assistant", message: { model, content: [] } } as unknown as SDKMessage; + } + + it("reports usage as the child works", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + // Real shape: task_progress fires once per tool use, not on a timer. + expect( + source.observe(taskProgress({ total_tokens: 16484, tool_uses: 1, duration_ms: 2474 })), + ).toEqual([ + { + kind: "subtitle", + id: "toolu_01DgLoPMW9", + subtitle: "general-purpose · 16.5k tokens", + }, + ]); + }); + + it("reports terminal usage and status together", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + const observations = source.observe({ + type: "system", + subtype: "task_notification", + task_id: "a1730a6215e1f5cf6", + tool_use_id: "toolu_01DgLoPMW9", + status: "completed", + usage: { total_tokens: 32271, tool_uses: 2, duration_ms: 10934 }, + } as unknown as SDKMessage); + + expect(observations).toEqual([ + { + kind: "subtitle", + id: "toolu_01DgLoPMW9", + subtitle: "general-purpose · 32.3k tokens", + }, + { kind: "status", id: "toolu_01DgLoPMW9", status: "completed" }, + ]); + }); + + it("ignores usage for a task it never declared", () => { + const source = new ClaudeTaskProtocolSource(); + expect(source.observe(taskProgress({ total_tokens: 1 }, "b51skux0z"))).toEqual([]); + }); + + it("reads the model off the child's own frames", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + expect( + source.observeSidechainFrame(assistantFrame("claude-opus-5"), "toolu_01DgLoPMW9"), + ).toEqual([{ kind: "subtitle", id: "toolu_01DgLoPMW9", subtitle: "general-purpose · Opus 5" }]); + }); + + it("re-reports only when the model actually changes", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.observeSidechainFrame(assistantFrame("claude-opus-5"), "toolu_01DgLoPMW9"); + // A dated alias is the same model; a mid-flight fallback is not. + expect( + source.observeSidechainFrame(assistantFrame("claude-opus-5-20260724"), "toolu_01DgLoPMW9"), + ).toEqual([]); + expect( + source.observeSidechainFrame(assistantFrame("claude-sonnet-5"), "toolu_01DgLoPMW9"), + ).toEqual([ + { kind: "subtitle", id: "toolu_01DgLoPMW9", subtitle: "general-purpose · Sonnet 5" }, + ]); + }); + + it("never reports a placeholder model", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + expect(source.observeSidechainFrame(assistantFrame(""), "toolu_01DgLoPMW9")).toEqual( + [], + ); + }); + + it("ignores a frame from a task it never declared", () => { + // A filtered task still emits frames carrying its tool_use id. Reporting a model for one + // would create a descriptor the declaration filter deliberately refused — with no title and + // a defaulted "running" status, which the track renders as a nameless row that never ends. + const source = new ClaudeTaskProtocolSource(); + source.observe( + taskStarted({ + task_id: "b51skux0z", + tool_use_id: "toolu_01MgVdcGPYnqE8cJQuccFtkU", + task_type: "local_bash", + subagent_type: undefined, + }), + ); + + expect( + source.observeSidechainFrame( + assistantFrame("claude-opus-5"), + "toolu_01MgVdcGPYnqE8cJQuccFtkU", + ), + ).toEqual([]); + expect(source.isDeclared("toolu_01MgVdcGPYnqE8cJQuccFtkU")).toBe(false); + }); + + it("never reports effort, which the live stream does not carry", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + const all = [ + ...source.observe(taskProgress({ total_tokens: 1 })), + ...source.observeSidechainFrame(assistantFrame("claude-opus-5"), "toolu_01DgLoPMW9"), + ]; + expect(all.some((o) => "effort" in o && o.effort !== undefined)).toBe(false); + }); +}); + +describe("ClaudeTaskProtocolSource effort from hooks", () => { + // Real hook shapes (Claude Code 2.1.220): agent_id equals task_started's task_id, and + // effort.level is the ACTIVE level after any silent downgrade. + function hook(overrides: Record = {}) { + return { + agent_id: "a1730a6215e1f5cf6", + agent_type: "general-purpose", + effort: { level: "high" }, + ...overrides, + }; + } + + it("reports effort for a declared subagent, routed by agent_id", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + expect(source.observeHook(hook())).toEqual([ + { kind: "subtitle", id: "toolu_01DgLoPMW9", subtitle: "general-purpose · High" }, + ]); + }); + + it("does not re-report an unchanged effort", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.observeHook(hook()); + // PreToolUse and PostToolUse both fire on every tool use. + expect(source.observeHook(hook())).toEqual([]); + }); + + it("reports a downgrade when the level actually changes", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + source.observeHook(hook()); + expect(source.observeHook(hook({ effort: { level: "medium" } }))).toEqual([ + { kind: "subtitle", id: "toolu_01DgLoPMW9", subtitle: "general-purpose · Medium" }, + ]); + }); + + it("ignores a hook from the parent turn", () => { + // Stop fires with effort but no agent_id: that is the parent, not a child. + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + expect(source.observeHook(hook({ agent_id: undefined }))).toEqual([]); + }); + + it("ignores a hook for a subagent it never declared", () => { + const source = new ClaudeTaskProtocolSource(); + expect(source.observeHook(hook())).toEqual([]); + }); + + it("ignores a hook that carries no effort", () => { + // SubagentStart fires without effort — documented as absent for lifecycle hooks. + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + expect(source.observeHook(hook({ effort: undefined }))).toEqual([]); + }); + + it("survives a hook input of an unexpected shape", () => { + const source = new ClaudeTaskProtocolSource(); + source.observe(taskStarted()); + expect(source.observeHook({} as never)).toEqual([]); + expect(source.observeHook({ agent_id: 42, effort: { level: 7 } } as never)).toEqual([]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts new file mode 100644 index 0000000000..c2b1996324 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -0,0 +1,395 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +import type { AgentMetadata } from "../../../agent-sdk-types.js"; +import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js"; +import { resolveObservedClaudeModelId } from "../models.js"; +import type { SubagentObservation } from "./observation.js"; +import { + buildClaudeSubagentSubtitle, + type ClaudeSubagentPresentationFacts, + type ClaudeSubagentUsage, +} from "./presentation.js"; + +/** + * Claude Code announces subagent lifecycle on the SDK stream. This reads those announcements + * instead of reconstructing them from sidechain frames. + * + * Verified on the wire (Claude Code 2.1.220, Paseo's own query options): + * + * task_started task_id, tool_use_id, description, subagent_type, task_type + * task_updated task_id, patch.status, patch.is_backgrounded + * task_notification task_id, tool_use_id, status + * + * Only `task_started` carries `tool_use_id`, so the mapping from task id to the canonical + * subagent id has to be remembered. The source also accumulates Claude's presentation facts so + * the shared descriptor receives one complete provider-owned subtitle rather than Claude fields. + * Neither is a lifecycle state machine: status comes directly from task announcements. + * + * The table is session-scoped, because task ids are. It survives a turn ending — the one thing a + * turn ending warrants is `cancelRunningForegroundTasks`, not forgetting the session. + */ + +interface TaskStartedMessage { + task_id: string; + tool_use_id?: string; + description?: string; + subagent_type?: string; + task_type?: string; + prompt?: string; + skip_transcript?: boolean; +} + +/** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */ +const CLAUDE_SUBAGENT_TASK_TYPE = "local_agent"; + +/** + * Not every announced task is a subagent. Verified on the wire: + * + * Task subagent task_type "local_agent", subagent_type "general-purpose" + * background shell task_type "local_bash", no subagent_type + * + * Both carry a `tool_use_id`, so presence of an id is not a discriminator — filtering on it + * alone puts `sleep 20` in the subagents track. Releases that predate `task_type` are covered + * by requiring a subagent type instead. + */ +function isSubagentTask(message: TaskStartedMessage): boolean { + if (message.task_type) return message.task_type === CLAUDE_SUBAGENT_TASK_TYPE; + return readString(message.subagent_type) !== undefined; +} + +interface TaskUpdatedMessage { + task_id: string; + /** + * `is_backgrounded` is declared on `SDKTaskUpdatedMessage["patch"]`: it flips when a foreground + * task is backgrounded, which is the only signal that separates a child that dies with its turn + * from one that was explicitly told to outlive it. + */ + patch?: { status?: string; is_backgrounded?: boolean }; +} + +interface TaskNotificationMessage { + task_id: string; + tool_use_id?: string; + status?: string; + usage?: { total_tokens?: number; tool_uses?: number; duration_ms?: number }; +} + +/** + * The subset of a hook input this source reads. Every hook shares these base fields: `agent_id` + * is set when the hook fires from inside a subagent, and `effort.level` is the ACTIVE level for + * that turn, after any silent downgrade for the selected model. + */ +export interface ClaudeHookObservationInput { + agent_id?: unknown; + agent_type?: unknown; + effort?: { level?: unknown } | undefined; +} + +interface TaskProgressMessage { + task_id: string; + usage?: { total_tokens?: number; tool_uses?: number; duration_ms?: number }; +} + +function readUsage( + usage: { total_tokens?: number; tool_uses?: number; duration_ms?: number } | undefined, +): ClaudeSubagentUsage | null { + if (!usage) return null; + const observed: ClaudeSubagentUsage = {}; + if (typeof usage.total_tokens === "number") observed.totalTokens = usage.total_tokens; + return Object.keys(observed).length > 0 ? observed : null; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** + * The descriptor has no paused or pending state, and neither is terminal, so both read as + * running. `killed` is a cancellation, not a failure — the child was stopped, it did not error. + */ +function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined { + switch (status) { + case "pending": + case "running": + case "paused": + return "running"; + case "completed": + return "completed"; + case "failed": + return "failed"; + case "killed": + case "stopped": + return "canceled"; + default: + return undefined; + } +} + +export interface ClaudeTaskProtocolSourceInput { + /** + * The parent's Task tool input, by tool_use id. + * + * `task_started` announces `subagent_type`, but the Task call itself may also carry an explicit + * `name`, and the replay source prefers that name over the type. Reading the same field from the + * same place is what keeps one subagent titled identically live and on reopen. + */ + getToolInput?: (toolUseId: string) => AgentMetadata | null | undefined; +} + +export class ClaudeTaskProtocolSource { + /** task_id -> canonical subagent id (the Task tool_use id). Populated by task_started. */ + private readonly subagentIdByTaskId = new Map(); + /** + * Every subagent id this source declared. It is the source's whole vocabulary: an id that is + * not in here was either filtered at declaration or never announced, and this source has + * nothing to say about it. + */ + private readonly declaredIds = new Set(); + /** + * Declared subagents that were moved to the background. They outlive the turn that spawned + * them, so a turn ending is not evidence that they stopped. + */ + private readonly backgroundedIds = new Set(); + /** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */ + private readonly lastStatusById = new Map(); + /** Claude facts stay inside the provider boundary; clients receive one compact subtitle. */ + private readonly presentationById = new Map(); + private readonly lastSubtitleById = new Map(); + private sawTaskStarted = false; + private sawAnyTask = false; + private readonly getToolInput: (toolUseId: string) => AgentMetadata | null | undefined; + + constructor(input: ClaudeTaskProtocolSourceInput = {}) { + this.getToolInput = input.getToolInput ?? (() => null); + } + + /** + * True once this session has announced at least one task. Older Claude Code releases predate + * the task protocol; callers use this to decide whether the legacy derivation still has work + * to do, rather than assuming a version. + */ + get isActive(): boolean { + return this.sawTaskStarted; + } + + /** + * True once this session announced any task at all, including the ones filtered out. + * + * Deliberately not `isActive`: that one answers "does a declarative owner exist for subagent + * descriptors", and stays false in a session whose only tasks were rejected. This one answers + * "does this CLI announce its tasks" — and once it does, every task is announced, so a frame + * for an id that was never declared is provably work the filter refused rather than something + * this source has yet to hear about. + */ + get announcesTasks(): boolean { + return this.sawAnyTask; + } + + /** + * Whether this source declared the given subagent. Callers route frames through this before + * attributing anything to an id: a frame for a task that was never declared belongs to work + * the declaration filter already rejected. + */ + isDeclared(subagentId: string): boolean { + return this.declaredIds.has(subagentId); + } + + observe(message: SDKMessage): SubagentObservation[] { + if (message.type !== "system") return []; + switch (message.subtype) { + case "task_started": + return this.observeTaskStarted(message as unknown as TaskStartedMessage); + case "task_updated": + return this.observeTaskUpdated(message as unknown as TaskUpdatedMessage); + case "task_notification": + return this.observeTaskNotification(message as unknown as TaskNotificationMessage); + case "task_progress": + return this.observeTaskProgress(message as unknown as TaskProgressMessage); + default: + return []; + } + } + + /** + * Forget the session. + * + * Task ids are session-scoped and outlive any single turn, so this belongs to session teardown + * alone. Clearing it when a turn ends would drop the routing table while its tasks were still + * live — see `cancelRunningForegroundTasks`, which is what a turn ending actually warrants. + */ + reset(): void { + this.subagentIdByTaskId.clear(); + this.declaredIds.clear(); + this.backgroundedIds.clear(); + this.lastStatusById.clear(); + this.presentationById.clear(); + this.lastSubtitleById.clear(); + this.sawTaskStarted = false; + this.sawAnyTask = false; + } + + /** + * Terminalize the subagents that a canceled turn was running. + * + * Cancellation is a fact about the turn, not about the task table: the session, and every id in + * it, outlives the turn. So this reports statuses and leaves the routing intact — that is what + * lets a child which settles after the interrupt still find its descriptor, and what keeps a + * later `task_notification` free to correct this guess. + * + * Backgrounded children are skipped. Being backgrounded is precisely the declaration that they + * outlive the turn; everything else was running in the foreground and died with it. + */ + cancelRunningForegroundTasks(): SubagentObservation[] { + const observations: SubagentObservation[] = []; + for (const id of this.declaredIds) { + if (this.backgroundedIds.has(id)) continue; + if (this.lastStatusById.get(id) !== "running") continue; + this.lastStatusById.set(id, "canceled"); + observations.push({ kind: "status", id, status: "canceled" }); + } + return observations; + } + + private observeTaskStarted(message: TaskStartedMessage): SubagentObservation[] { + // Recorded before the filter: what this proves is that the CLI announces its tasks, which is + // true whether or not this particular one is a subagent. + this.sawAnyTask = true; + + const id = readString(message.tool_use_id); + // skip_transcript marks ambient housekeeping the transcript should not show. + if (!id || message.skip_transcript === true || !isSubagentTask(message)) return []; + + this.sawTaskStarted = true; + this.subagentIdByTaskId.set(message.task_id, id); + this.declaredIds.add(id); + this.lastStatusById.set(id, "running"); + + // An explicit `name` on the Task call wins over the agent type, matching how replay titles the + // same subagent. Without it a fan-out of five Explores reads as five identical rows. + const title = readString(this.getToolInput(id)?.name) ?? readString(message.subagent_type); + const description = readString(message.description); + const observations: SubagentObservation[] = [ + { + kind: "declared", + id, + toolCallId: id, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + }, + ]; + const initialPresentation = title ? { title } : {}; + this.presentationById.set(id, initialPresentation); + const initialSubtitle = buildClaudeSubagentSubtitle(initialPresentation); + if (initialSubtitle) this.lastSubtitleById.set(id, initialSubtitle); + + // Open the child's timeline with the prompt it was actually given. Without this the pane + // starts mid-conversation, showing replies to a question the reader never sees. + const prompt = readString(message.prompt); + if (prompt) { + observations.push({ kind: "timeline", id, item: { type: "user_message", text: prompt } }); + } + return observations; + } + + private observeTaskUpdated(message: TaskUpdatedMessage): SubagentObservation[] { + const id = this.subagentIdByTaskId.get(message.task_id); + const backgrounded = message.patch?.is_backgrounded; + if (id && typeof backgrounded === "boolean") { + if (backgrounded) this.backgroundedIds.add(id); + else this.backgroundedIds.delete(id); + } + return this.observeStatus(message.task_id, message.patch?.status); + } + + private observeTaskNotification(message: TaskNotificationMessage): SubagentObservation[] { + const observations = this.observeUsage(message.task_id, message.usage); + observations.push(...this.observeStatus(message.task_id, message.status)); + return observations; + } + + /** + * `task_progress` fires once per tool use, not on a timer, so this is an accurate live cost + * signal rather than a sampled one. It matters most for backgrounded subagents, which emit no + * sidechain frames at all and would otherwise show no activity while they work. + */ + private observeTaskProgress(message: TaskProgressMessage): SubagentObservation[] { + return this.observeUsage(message.task_id, message.usage); + } + + private observeUsage( + taskId: string, + raw: { total_tokens?: number; tool_uses?: number; duration_ms?: number } | undefined, + ): SubagentObservation[] { + const id = this.subagentIdByTaskId.get(taskId); + const usage = readUsage(raw); + if (!id || !usage) return []; + return this.updatePresentation(id, { usage }); + } + + /** + * Effort as reported by a hook firing inside a subagent. + * + * This is the only live source: the message stream carries no effort at any depth, and the + * value Paseo requested is not necessarily the one that ran, because a model that does not + * support the requested level is silently downgraded. Hooks report the post-downgrade level. + * + * `agent_id` is the same id `task_started` calls `task_id`, so it routes through the table + * already built at declaration — no second identity to reconcile. + */ + observeHook(input: ClaudeHookObservationInput): SubagentObservation[] { + const taskId = readString(input.agent_id); + const effort = readString(input.effort?.level); + if (!taskId || !effort) return []; + const id = this.subagentIdByTaskId.get(taskId); + if (!id) return []; + return this.updatePresentation(id, { effort }); + } + + /** + * The model the child is actually running, read off its own assistant frames. + * + * Routed through the declaration for the same reason status is: a frame carrying the tool_use + * id of a task this source filtered out — ambient housekeeping, a workflow child, a nested + * grandchild announced in someone else's session — would otherwise fold to an upsert with no + * identity and a defaulted "running" status. That is the nameless, never-finishing row the + * declaration filter exists to prevent, arriving by a different door. + */ + observeSidechainFrame(message: SDKMessage, subagentId: string): SubagentObservation[] { + if (message.type !== "assistant" || !this.declaredIds.has(subagentId)) return []; + const model = resolveObservedClaudeModelId( + typeof message.message?.model === "string" ? message.message.model : undefined, + ); + if (!model) return []; + return this.updatePresentation(subagentId, { model }); + } + + private updatePresentation( + id: string, + patch: ClaudeSubagentPresentationFacts, + ): SubagentObservation[] { + const previous = this.presentationById.get(id) ?? {}; + const next: ClaudeSubagentPresentationFacts = { ...previous, ...patch }; + this.presentationById.set(id, next); + const subtitle = buildClaudeSubagentSubtitle(next); + if (!subtitle || this.lastSubtitleById.get(id) === subtitle) return []; + this.lastSubtitleById.set(id, subtitle); + return [{ kind: "subtitle", id, subtitle }]; + } + + /** + * Status is routed only through a task this source declared. + * + * `task_notification` also fires for the tasks deliberately filtered above, and it carries a + * `tool_use_id`. Falling back to that id would readmit exactly what the filter rejected — as a + * descriptor holding a status and no identity, which the track renders as a nameless row. + * A status without a declaration describes nothing, so it is dropped. + */ + private observeStatus(taskId: string, rawStatus: string | undefined): SubagentObservation[] { + const id = this.subagentIdByTaskId.get(taskId); + if (!id) return []; + const status = mapTaskStatus(rawStatus); + if (!status || this.lastStatusById.get(id) === status) return []; + this.lastStatusById.set(id, status); + return [{ kind: "status", id, status }]; + } +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/observation.test.ts b/packages/server/src/server/agent/providers/claude/subagents/observation.test.ts new file mode 100644 index 0000000000..3690334af0 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/observation.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { ProviderSubagentStore } from "../../../provider-subagents/store.js"; +import { foldSubagentObservations, type SubagentObservation } from "./observation.js"; + +describe("foldSubagentObservations", () => { + it("turns a declaration into a running upsert carrying the task", () => { + expect( + foldSubagentObservations([ + { + kind: "declared", + id: "toolu_1", + title: "Explore", + description: "Find subagent title code", + toolCallId: "toolu_1", + }, + ]), + ).toEqual([ + { + type: "upsert", + id: "toolu_1", + status: "running", + title: "Explore", + description: "Find subagent title code", + toolCallId: "toolu_1", + }, + ]); + }); + + it("emits status alone so the store's sticky merge keeps identity", () => { + expect( + foldSubagentObservations([{ kind: "status", id: "toolu_1", status: "completed" }]), + ).toEqual([{ type: "upsert", id: "toolu_1", status: "completed" }]); + }); + + it("omits absent fields rather than clearing them", () => { + const [event] = foldSubagentObservations([{ kind: "declared", id: "toolu_1" }]); + expect(Object.keys(event ?? {}).sort()).toEqual(["id", "status", "type"]); + }); + + it("distinguishes an explicitly null-ish description from an absent one", () => { + // An empty description is a real observation of "no task text", not "unknown". + const [event] = foldSubagentObservations([ + { kind: "declared", id: "toolu_1", description: "" }, + ]); + expect(event).toMatchObject({ description: "" }); + }); + + it("passes timeline items through with their timestamp", () => { + const item = { type: "reasoning", text: "thinking" } as const; + expect( + foldSubagentObservations([ + { kind: "timeline", id: "toolu_1", item, timestamp: "2026-07-26T00:00:00.000Z" }, + ]), + ).toEqual([{ type: "timeline", id: "toolu_1", item, timestamp: "2026-07-26T00:00:00.000Z" }]); + }); + + it("is stateless: folding the same observation twice yields the same event", () => { + const observation: SubagentObservation = { + kind: "declared", + id: "toolu_1", + title: "Explore", + }; + expect(foldSubagentObservations([observation])).toEqual( + foldSubagentObservations([observation]), + ); + }); + + it("accumulates through the store so a later status keeps the declared identity", () => { + // The property the whole design rests on: sources emit sparse facts, the store merges them. + const store = new ProviderSubagentStore(); + const events = foldSubagentObservations([ + { kind: "declared", id: "toolu_1", title: "Explore", description: "Find the code" }, + { kind: "status", id: "toolu_1", status: "completed" }, + ]); + + let descriptor = null; + for (const event of events) { + const applied = store.apply("parent", "claude", event); + if (applied.type === "upsert") descriptor = applied.subagent; + } + + expect(descriptor).toMatchObject({ + id: "toolu_1", + title: "Explore", + description: "Find the code", + status: "completed", + }); + }); +}); + +describe("subtitle observations", () => { + it("reports provider context without asserting a status", () => { + const [event] = foldSubagentObservations([ + { kind: "subtitle", id: "toolu_1", subtitle: "Explore · Opus 5 · High" }, + ]); + expect(event).toEqual({ + type: "upsert", + id: "toolu_1", + subtitle: "Explore · Opus 5 · High", + }); + expect(event).not.toHaveProperty("status"); + }); + + it("does not revert a finished subagent when presentation changes", () => { + const store = new ProviderSubagentStore(); + let descriptor = null; + for (const event of foldSubagentObservations([ + { kind: "declared", id: "toolu_1", title: "Explore" }, + { kind: "status", id: "toolu_1", status: "completed" }, + { kind: "subtitle", id: "toolu_1", subtitle: "Explore · Opus 5 · 32.3k tokens" }, + ])) { + const applied = store.apply("parent", "claude", event); + if (applied.type === "upsert") descriptor = applied.subagent; + } + expect(descriptor).toMatchObject({ + status: "completed", + subtitle: "Explore · Opus 5 · 32.3k tokens", + }); + }); + + it("keeps identity when a later subtitle arrives", () => { + const store = new ProviderSubagentStore(); + let descriptor = null; + for (const event of foldSubagentObservations([ + { kind: "declared", id: "toolu_1", title: "Explore", description: "Find the code" }, + { kind: "subtitle", id: "toolu_1", subtitle: "Explore · Opus 5" }, + ])) { + const applied = store.apply("parent", "claude", event); + if (applied.type === "upsert") descriptor = applied.subagent; + } + expect(descriptor).toMatchObject({ + title: "Explore", + description: "Find the code", + subtitle: "Explore · Opus 5", + status: "running", + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/observation.ts b/packages/server/src/server/agent/providers/claude/subagents/observation.ts new file mode 100644 index 0000000000..718a9fc10a --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/observation.ts @@ -0,0 +1,91 @@ +import type { AgentTimelineItem } from "../../../agent-sdk-types.js"; +import type { + ProviderSubagentInputEvent, + ProviderSubagentStatus, +} from "../../../provider-subagents/store.js"; + +/** + * A single fact a provider observed about one subagent. + * + * Sources translate their wire format into observations. Claude-specific presentation is reduced + * to a subtitle before it crosses this boundary, while lifecycle and timeline facts stay + * structural. Live and on-disk replay therefore speak one vocabulary. + * + * The union stays small on purpose: a kind earns its place when a descriptor field exists to + * receive it and a source exists to report it. + */ +export type SubagentObservation = + | { + /** The subagent exists, and here is what it was asked to do. */ + kind: "declared"; + id: string; + /** Subagent type, e.g. "Explore". Identical across a fan-out — not the task. */ + title?: string; + /** The task this child was given. What actually distinguishes it from its siblings. */ + description?: string; + toolCallId?: string; + timestamp?: string; + } + | { kind: "status"; id: string; status: ProviderSubagentStatus; timestamp?: string } + /** Complete provider-owned secondary label. Clients display this without parsing it. */ + | { kind: "subtitle"; id: string; subtitle: string; timestamp?: string } + | { kind: "timeline"; id: string; item: AgentTimelineItem; timestamp?: string }; + +/** + * Turn observations into store events. + * + * Pure and stateless by construction. Both the task protocol and a persisted transcript are + * edge-triggered — a subagent is declared once and its status changes only when it changes — so + * there is nothing to debounce here. Accumulation across partial upserts is the store's sticky + * merge (omitted field preserves, explicit null clears), which is why a `status` observation can + * carry status alone without blanking the title. + */ +export function foldSubagentObservations( + observations: readonly SubagentObservation[], +): ProviderSubagentInputEvent[] { + const events: ProviderSubagentInputEvent[] = []; + for (const observation of observations) { + if (observation.kind === "timeline") { + events.push({ + type: "timeline", + id: observation.id, + item: observation.item, + ...(observation.timestamp ? { timestamp: observation.timestamp } : {}), + }); + continue; + } + + if (observation.kind === "subtitle") { + events.push({ + type: "upsert", + id: observation.id, + subtitle: observation.subtitle, + ...(observation.timestamp ? { timestamp: observation.timestamp } : {}), + }); + continue; + } + + if (observation.kind === "status") { + events.push({ + type: "upsert", + id: observation.id, + status: observation.status, + ...(observation.timestamp ? { timestamp: observation.timestamp } : {}), + }); + continue; + } + + // A declared subagent is running until something says otherwise. Its terminal status + // arrives as its own observation, so this never has to guess at completion. + events.push({ + type: "upsert", + id: observation.id, + status: "running", + ...(observation.title === undefined ? {} : { title: observation.title }), + ...(observation.description === undefined ? {} : { description: observation.description }), + ...(observation.toolCallId === undefined ? {} : { toolCallId: observation.toolCallId }), + ...(observation.timestamp ? { timestamp: observation.timestamp } : {}), + }); + } + return events; +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/presentation.test.ts b/packages/server/src/server/agent/providers/claude/subagents/presentation.test.ts new file mode 100644 index 0000000000..6fd2b191a0 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/presentation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { buildClaudeSubagentSubtitle } from "./presentation.js"; + +describe("buildClaudeSubagentSubtitle", () => { + it("formats Claude facts into one compact provider-owned label", () => { + expect( + buildClaudeSubagentSubtitle({ + title: "general-purpose", + model: "claude-opus-5", + effort: "high", + usage: { totalTokens: 16_484 }, + }), + ).toBe("general-purpose · Opus 5 · High · 16.5k tokens"); + }); + + it("uses the manifest label for dated and context-window model variants", () => { + expect(buildClaudeSubagentSubtitle({ model: "claude-opus-4-8[1m]" })).toBe("Opus 4.8 1M"); + }); + + it("keeps unknown compatible-provider model names visible", () => { + expect(buildClaudeSubagentSubtitle({ model: "glm-5.1" })).toBe("glm-5.1"); + }); + + it("omits facts that were not observed", () => { + expect(buildClaudeSubagentSubtitle({ title: "Explore", usage: { totalTokens: 0 } })).toBe( + "Explore", + ); + expect(buildClaudeSubagentSubtitle({})).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/presentation.ts b/packages/server/src/server/agent/providers/claude/subagents/presentation.ts new file mode 100644 index 0000000000..87fb73f296 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/presentation.ts @@ -0,0 +1,53 @@ +import { findClaudeModel } from "../models.js"; + +export interface ClaudeSubagentUsage { + totalTokens?: number; +} + +export interface ClaudeSubagentPresentationFacts { + title?: string; + model?: string; + effort?: string; + usage?: ClaudeSubagentUsage; +} + +/** Build the complete compact subtitle Claude exposes to provider-neutral clients. */ +export function buildClaudeSubagentSubtitle( + facts: ClaudeSubagentPresentationFacts, +): string | undefined { + const parts = [ + readPart(facts.title), + formatModel(facts.model), + formatEffort(facts.effort), + formatTokens(facts.usage?.totalTokens), + ].filter((part): part is string => part !== undefined); + return parts.length > 0 ? parts.join(" · ") : undefined; +} + +function readPart(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function formatModel(modelId: string | undefined): string | undefined { + const normalized = readPart(modelId); + if (!normalized) return undefined; + return findClaudeModel(normalized)?.label ?? normalized; +} + +function formatEffort(effort: string | undefined): string | undefined { + const normalized = readPart(effort); + if (!normalized) return undefined; + if (normalized === "xhigh") return "Extra High"; + return normalized + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatTokens(totalTokens: number | undefined): string | undefined { + if (typeof totalTokens !== "number" || totalTokens <= 0) return undefined; + if (totalTokens < 1000) return `${Math.round(totalTokens)} tokens`; + return `${Math.round(totalTokens / 100) / 10}k tokens`; +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts new file mode 100644 index 0000000000..e47fe817fe --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.test.ts @@ -0,0 +1,497 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { describe, expect, it } from "vitest"; + +import { + ProviderSubagentStore, + type ProviderSubagentDescriptor, +} from "../../../provider-subagents/store.js"; +import { ClaudeTaskProtocolSource } from "./live-source.js"; +import { foldSubagentObservations, type SubagentObservation } from "./observation.js"; +import { + observeReplaySubagents, + parseClaudeSubagentMeta, + type ClaudeReplayEntry, + type ClaudeReplayParentFacts, +} from "./replay-source.js"; + +const TOOL_USE_ID = "toolu_01DgLoPMW9"; +const AGENT_ID = "a1730a6215e1f5cf6"; + +function emptyParent(): ClaudeReplayParentFacts { + return { toolCalls: new Map(), linksByAgentId: new Map(), outcomesByToolCallId: new Map() }; +} + +function parentWithTaskCall(failed = false): ClaudeReplayParentFacts { + return { + toolCalls: new Map([ + [ + TOOL_USE_ID, + { title: "general-purpose", description: "Summarize hover and unistyles docs" }, + ], + ]), + linksByAgentId: new Map(), + outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed }]]), + }; +} + +function applyToStore(observations: SubagentObservation[]): ProviderSubagentDescriptor | null { + const store = new ProviderSubagentStore(); + let descriptor: ProviderSubagentDescriptor | null = null; + for (const event of foldSubagentObservations(observations)) { + const applied = store.apply("parent", "claude", event); + if (applied.type === "upsert") descriptor = applied.subagent; + } + return descriptor; +} + +describe("parseClaudeSubagentMeta", () => { + it("reads the real sidecar shape", () => { + expect( + parseClaudeSubagentMeta( + '{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}', + ), + ).toEqual({ + agentType: "general-purpose", + description: "Reply banana", + toolUseId: "toolu_013i", + spawnDepth: 1, + }); + }); + + it.each([ + ["truncated json", '{"agentType":"Explore"'], + ["not json at all", "agentType: Explore"], + ["empty", ""], + ["a json array", "[1,2,3]"], + ["an object with no known fields", '{"unrelated":true}'], + ])("treats %s as absent rather than throwing", (_label, contents) => { + expect(parseClaudeSubagentMeta(contents)).toBeNull(); + }); + + it("keeps the good fields when one field has the wrong type", () => { + expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({ + agentType: "Explore", + description: undefined, + toolUseId: undefined, + spawnDepth: undefined, + }); + }); +}); + +describe("observeReplaySubagents", () => { + it("links a subagent to its Task call through meta.toolUseId", () => { + const observations = observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }); + + expect(observations[0]).toMatchObject({ + kind: "declared", + id: TOOL_USE_ID, + toolCallId: TOOL_USE_ID, + title: "general-purpose", + description: "Summarize hover and unistyles docs", + }); + }); + + it("replays only this session's own children, not their descendants", () => { + // Claude Code writes every descendant into the same subagents/ directory. A grandchild's + // toolUseId names a Task call made inside its parent's session, so nothing here can resolve + // it: it would replay as an extra row the live stream never showed, with no Task card and no + // outcome, running forever. One recorded session showed 10 subagents live and 22 on reopen. + const observations = observeReplaySubagents({ + subagents: [ + { agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, entries: [] }, + { + agentId: "a6acb4b898", + meta: { toolUseId: "toolu_012rzYnFZA", agentType: "Explore", spawnDepth: 2 }, + entries: [], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }); + + expect([...new Set(observations.map((observation) => observation.id))]).toEqual([TOOL_USE_ID]); + }); + + it("keeps a subagent recorded before spawnDepth existed", () => { + const observations = observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }); + + expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID }); + }); + + it("recovers identity from meta when the parent's Task call is missing", () => { + // The case the legacy scrape cannot handle: no tool_result, so no link and no identity. + const observations = observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" }, + entries: [], + }, + ], + parent: emptyParent(), + convertEntry: () => [], + }); + + expect(observations[0]).toMatchObject({ + id: TOOL_USE_ID, + toolCallId: TOOL_USE_ID, + title: "Explore", + description: "Find the code", + }); + }); + + it("falls back to the scraped link when there is no meta file", () => { + const parent: ClaudeReplayParentFacts = { + toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]), + linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]), + outcomesByToolCallId: new Map(), + }; + + const [declared, ...rest] = observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent, + convertEntry: () => [], + }); + + expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" }); + expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" }); + }); + + it("keeps an unlinkable subagent visible under its own id", () => { + const [declared] = observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }], + parent: emptyParent(), + convertEntry: () => [], + }); + expect(declared).toMatchObject({ id: AGENT_ID }); + expect(declared).not.toHaveProperty("toolCallId"); + }); + + it("leaves a subagent with no recorded outcome running", () => { + const observations = observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() }, + convertEntry: () => [], + }); + expect(observations.some((o) => o.kind === "status")).toBe(false); + }); + + it("reports a failed outcome as failed", () => { + const observations = observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(true), + convertEntry: () => [], + }); + expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" }); + }); + + it("carries entry timestamps onto timeline observations", () => { + const observations = observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [{ type: "reasoning", text: "thinking" }], + }); + + expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({ + kind: "timeline", + id: TOOL_USE_ID, + timestamp: "2026-07-26T06:27:47.034Z", + }); + }); +}); + +describe("replay runtime", () => { + it("reads the model and effort off the subagent's own transcript", () => { + const observations = observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }); + + expect(observations.find((o) => o.kind === "subtitle")).toEqual({ + kind: "subtitle", + id: TOOL_USE_ID, + subtitle: "general-purpose · Opus 5 · High", + }); + }); + + it("takes the last observed values when the model changes mid-transcript", () => { + const observations = observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { type: "assistant", effort: "high", message: { model: "claude-opus-5" } }, + { type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } }, + ], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }); + + expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ + subtitle: "general-purpose · Sonnet 5 · Low", + }); + }); + + it("reports nothing when the transcript never names a model or effort", () => { + const observations = observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }); + expect(observations.some((o) => o.kind === "subtitle")).toBe(false); + }); + + it("passes through a model the manifest does not know", () => { + // Claude Code is an Anthropic-compatible client; subagents legitimately report other models. + const observations = observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [{ type: "assistant", message: { model: "glm-5.1" } }], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }); + expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({ + subtitle: "general-purpose · glm-5.1", + }); + }); +}); + +describe("replay usage", () => { + function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined { + return observeReplaySubagents({ + subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }], + parent: parentWithTaskCall(), + convertEntry: () => [], + }).find((observation) => observation.kind === "subtitle"); + } + + it("derives the counters Claude Code itself reports, from a real transcript's shape", () => { + // Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a + // subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win + // and must not be added in. + expect( + subtitleOf([ + { + type: "assistant", + timestamp: "2026-07-23T23:18:43.023Z", + message: { + model: "claude-opus-5", + content: [ + { type: "tool_use", name: "Read" }, + { type: "text", text: "hi" }, + ], + usage: { + input_tokens: 4, + cache_creation_input_tokens: 15_000, + cache_read_input_tokens: 0, + output_tokens: 120, + }, + }, + }, + { type: "user", timestamp: "2026-07-23T23:20:00.000Z", message: { content: [] } }, + { + type: "assistant", + timestamp: "2026-07-23T23:49:05.068Z", + message: { + model: "claude-opus-5", + content: [{ type: "tool_use", name: "Grep" }], + usage: { + input_tokens: 2293, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 65_024, + output_tokens: 1376, + }, + }, + }, + ]), + ).toEqual({ + kind: "subtitle", + id: TOOL_USE_ID, + // 2293 + 0 + 65024 + 1376 — the last turn's context, matching the live path's total_tokens. + subtitle: "general-purpose · Opus 5 · 68.7k tokens", + timestamp: "2026-07-23T23:49:05.068Z", + }); + }); + + it("formats tokens for a child that only answered", () => { + expect( + subtitleOf([ + { + type: "assistant", + timestamp: "2026-07-27T16:42:14.698Z", + message: { content: [{ type: "text", text: "done" }], usage: { input_tokens: 16_434 } }, + }, + { + type: "user", + timestamp: "2026-07-27T16:42:16.926Z", + message: { content: "ignored" }, + }, + ]), + ).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" }); + }); + + it("emits nothing for a transcript with no entries", () => { + expect(subtitleOf([])).toBeUndefined(); + }); + + it("keeps duration out of the compact subtitle", () => { + const observation = subtitleOf([ + { + type: "assistant", + timestamp: "2026-07-27T16:42:14.698Z", + message: { content: [], usage: { input_tokens: 10 } }, + }, + ]); + expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" }); + }); + + it("omits totalTokens when no entry carries a usage block", () => { + const observation = subtitleOf([ + { type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } }, + { + type: "assistant", + timestamp: "2026-07-27T16:42:20.000Z", + message: { content: [{ type: "tool_use", name: "Bash" }], usage: "not-an-object" }, + }, + ]); + expect(observation).toBeUndefined(); + }); + + it("keeps the last usage-bearing entry when the final assistant entry has none", () => { + expect( + subtitleOf([ + { + type: "assistant", + timestamp: "2026-07-27T16:42:14.698Z", + message: { content: [], usage: { input_tokens: 900, output_tokens: 100 } }, + }, + { type: "assistant", timestamp: "2026-07-27T16:42:20.000Z", message: { content: [] } }, + ]), + ).toMatchObject({ subtitle: "general-purpose · 1k tokens" }); + }); + + it("only reports counters the transcript actually shows", () => { + // A user-only transcript says nothing about cost: no assistant turn, so no tool count either. + const observation = subtitleOf([ + { type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } }, + { type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } }, + ]); + expect(observation).toBeUndefined(); + }); + + it("lands the complete provider-owned subtitle on the descriptor", () => { + const store = new ProviderSubagentStore(); + const observations = observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { toolUseId: TOOL_USE_ID }, + entries: [ + { + type: "assistant", + timestamp: "2026-07-27T16:42:14.698Z", + message: { content: [], usage: { input_tokens: 7 } }, + }, + ], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }); + for (const event of foldSubagentObservations(observations)) { + store.apply("parent", "claude", event); + } + + expect(store.get("parent", TOOL_USE_ID)?.subtitle).toBe("general-purpose · 7 tokens"); + }); +}); + +describe("live and replay agree", () => { + // The property the design exists to guarantee: one session, two sources, one descriptor. + // This is inexpressible against the inference implementation, where the paths share no + // vocabulary and each derives identity and status its own way. + it("produces identical descriptor state from the wire and from disk", () => { + const live = new ClaudeTaskProtocolSource(); + const liveObservations = [ + ...live.observe({ + type: "system", + subtype: "task_started", + task_id: AGENT_ID, + tool_use_id: TOOL_USE_ID, + description: "Summarize hover and unistyles docs", + subagent_type: "general-purpose", + task_type: "local_agent", + } as unknown as SDKMessage), + ...live.observe({ + type: "system", + subtype: "task_updated", + task_id: AGENT_ID, + patch: { status: "completed" }, + } as unknown as SDKMessage), + ]; + + const replayObservations = observeReplaySubagents({ + subagents: [ + { + agentId: AGENT_ID, + meta: { + toolUseId: TOOL_USE_ID, + agentType: "general-purpose", + description: "Summarize hover and unistyles docs", + }, + entries: [], + }, + ], + parent: parentWithTaskCall(), + convertEntry: () => [], + }); + + const fromWire = applyToStore(liveObservations); + const fromDisk = applyToStore(replayObservations); + + const identity = (descriptor: ProviderSubagentDescriptor | null) => ({ + id: descriptor?.id, + title: descriptor?.title, + description: descriptor?.description, + status: descriptor?.status, + toolCallId: descriptor?.toolCallId, + }); + + expect(identity(fromWire)).toEqual(identity(fromDisk)); + expect(identity(fromWire)).toEqual({ + id: TOOL_USE_ID, + title: "general-purpose", + description: "Summarize hover and unistyles docs", + status: "completed", + toolCallId: TOOL_USE_ID, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts new file mode 100644 index 0000000000..61fc2c54f4 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/subagents/replay-source.ts @@ -0,0 +1,312 @@ +import { z } from "zod"; + +import type { AgentTimelineItem } from "../../../agent-sdk-types.js"; +import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js"; +import { resolveObservedClaudeModelId } from "../models.js"; +import type { SubagentObservation } from "./observation.js"; +import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js"; + +/** + * Rebuilds subagent observations from a persisted session, producing the same vocabulary the + * live task-protocol source produces. Identical observations in means identical descriptor + * state out, so a fact is derived once for both paths rather than once per path. + * + * Claude Code writes each subagent as `/subagents/agent-.jsonl` beside an + * `agent-.meta.json`. The meta file carries the Task `tool_use_id`, which is the same + * canonical id the live stream uses — it is the bridge that lets the two paths agree. + */ + +const ClaudeSubagentMetaSchema = z.object({ + agentType: z.string().optional().catch(undefined), + description: z.string().optional().catch(undefined), + toolUseId: z.string().optional().catch(undefined), + spawnDepth: z.number().optional().catch(undefined), +}); + +export type ClaudeSubagentMeta = z.infer; + +/** + * Parse a sidecar meta file. This is undocumented Claude Code internals, so every field is + * optional and a malformed file is indistinguishable from an absent one: callers fall back to + * the parent-transcript derivation rather than losing the subagent. + */ +export function parseClaudeSubagentMeta(contents: string): ClaudeSubagentMeta | null { + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch { + return null; + } + const result = ClaudeSubagentMetaSchema.safeParse(parsed); + if (!result.success) return null; + const meta = result.data; + const hasAnyField = + meta.agentType !== undefined || + meta.description !== undefined || + meta.toolUseId !== undefined || + meta.spawnDepth !== undefined; + return hasAnyField ? meta : null; +} + +export interface ClaudeReplayEntry { + type?: unknown; + agentId?: unknown; + timestamp?: unknown; + /** Claude Code stamps the active effort on each assistant entry. */ + effort?: unknown; + message?: { content?: unknown; [key: string]: unknown }; + [key: string]: unknown; +} + +/** + * Last-observed model and effort from a subagent's own assistant entries. + * + * Effort is replay-only: the SDK's live assistant message does not carry it, and it can be + * silently downgraded for the selected model, so any live value would be a guess. + */ +function readRuntime(entries: readonly ClaudeReplayEntry[]): { model?: string; effort?: string } { + const runtime: { model?: string; effort?: string } = {}; + for (const entry of entries) { + if (entry.type !== "assistant") continue; + const effort = typeof entry.effort === "string" ? entry.effort.trim() : ""; + if (effort) runtime.effort = effort; + const rawModel = (entry.message as { model?: unknown } | undefined)?.model; + const model = resolveObservedClaudeModelId(typeof rawModel === "string" ? rawModel : undefined); + if (model) runtime.model = model; + } + return runtime; +} + +/** + * Token counters as Claude Code stamps them onto an assistant entry. Undocumented internals, so + * every field is optional and a wrong-typed field reads as absent rather than failing the entry. + */ +const ClaudeReplayUsageSchema = z.object({ + input_tokens: z.number().optional().catch(undefined), + cache_creation_input_tokens: z.number().optional().catch(undefined), + cache_read_input_tokens: z.number().optional().catch(undefined), + output_tokens: z.number().optional().catch(undefined), +}); + +/** + * The one number the live path calls `total_tokens`. + * + * This is not a guess. Claude Code 2.1.220 finalizes a subagent by summing the usage block of the + * LAST assistant message — `input + (cache_creation ?? 0) + (cache_read ?? 0) + output` — and ships + * that as `usage.total_tokens` on `task_notification`; `task_progress` reports the same sum from + * the live tracker as it climbs. So it is a context-size reading at the final turn, not a + * cumulative spend across turns, which is why a small subagent reports a number in the tens of + * thousands: the reused prompt cache dominates it. Summing per-entry usage instead would multiply + * the cached prefix by the turn count and report a number several times larger than the live path. + */ +function readTotalTokens(raw: unknown): number | undefined { + const parsed = ClaudeReplayUsageSchema.safeParse(raw); + if (!parsed.success) return undefined; + const { input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens } = + parsed.data; + const counters = [ + input_tokens, + cache_creation_input_tokens, + cache_read_input_tokens, + output_tokens, + ]; + // An object with none of the four counters is not a usage block; reporting 0 would claim the + // subagent spent nothing. + if (counters.every((counter) => counter === undefined)) return undefined; + return counters.reduce((total: number, counter) => total + (counter ?? 0), 0); +} + +/** + * Token context of a subagent's run, recovered from its own transcript. + * + * Descriptors are in-memory and dropped when the agent closes, so without this a reopened agent + * permanently loses its children's counters. The live path gets these numbers handed to it by + * Claude Code; here they are re-derived to match those definitions: + * + * The last assistant entry's summed usage matches Claude Code's live `total_tokens` definition. + */ +function readUsage(entries: readonly ClaudeReplayEntry[]): ClaudeSubagentUsage | null { + let totalTokens: number | undefined; + + for (const entry of entries) { + if (entry.type !== "assistant") continue; + const observed = readTotalTokens(entry.message?.usage); + if (observed !== undefined) totalTokens = observed; + } + + return totalTokens === undefined ? null : { totalTokens }; +} + +export interface ClaudeReplaySubagentInput { + agentId: string; + meta: ClaudeSubagentMeta | null; + entries: ClaudeReplayEntry[]; +} + +export interface ClaudeReplayParentFacts { + /** Task `tool_use` id -> identity declared in the parent's tool input. */ + toolCalls: ReadonlyMap; + /** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */ + linksByAgentId: ReadonlyMap; + /** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */ + outcomesByToolCallId: ReadonlyMap; +} + +interface ResolvedLink { + id: string; + toolCallId: string | null; + failed: boolean | null; +} + +/** + * Resolve the canonical subagent id. + * + * `meta.toolUseId` is authoritative: it is the same id the live stream keys on. The legacy path + * scrapes `agentId:` out of stringified tool-result content, which both misses the link when the + * parent's tool_result is absent and matches unrelated text — on a real five-subagent session it + * produced eight ids, inventing `z`, `string`, and `update`. It stays only as a fallback for + * sessions recorded before the meta file existed. + */ +function resolveLink( + subagent: ClaudeReplaySubagentInput, + parent: ClaudeReplayParentFacts, +): ResolvedLink { + const metaToolUseId = subagent.meta?.toolUseId?.trim(); + if (metaToolUseId) { + const outcome = parent.outcomesByToolCallId.get(metaToolUseId); + return { + id: metaToolUseId, + toolCallId: metaToolUseId, + failed: outcome ? outcome.failed : null, + }; + } + + const scraped = parent.linksByAgentId.get(subagent.agentId); + if (scraped) { + return { id: scraped.toolCallId, toolCallId: scraped.toolCallId, failed: scraped.failed }; + } + + // No link at all: keep the subagent visible under its own agent id rather than dropping it. + return { id: subagent.agentId, toolCallId: null, failed: null }; +} + +/** + * The parent's tool input is the same source the live path reads, so it wins; meta.json fills the + * gap when the parent's Task call is missing from the transcript. + */ +function declareSubagent( + subagent: ClaudeReplaySubagentInput, + link: ResolvedLink, + toolCall: { title?: string; description?: string } | undefined, +): SubagentObservation { + const title = toolCall?.title ?? subagent.meta?.agentType; + const description = toolCall?.description ?? subagent.meta?.description; + const firstTimestamp = normalizeProviderReplayTimestamp(subagent.entries[0]?.timestamp); + return { + kind: "declared", + id: link.id, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + ...(link.toolCallId ? { toolCallId: link.toolCallId } : {}), + ...(firstTimestamp ? { timestamp: firstTimestamp } : {}), + }; +} + +function observeSubtitle( + subagent: ClaudeReplaySubagentInput, + link: ResolvedLink, + title: string | undefined, +): SubagentObservation | null { + const runtime = readRuntime(subagent.entries); + const usage = readUsage(subagent.entries); + const hasDetails = + runtime.model !== undefined || + runtime.effort !== undefined || + (usage?.totalTokens !== undefined && usage.totalTokens > 0); + if (!hasDetails) return null; + const subtitle = buildClaudeSubagentSubtitle({ + title, + ...runtime, + ...(usage ? { usage } : {}), + }); + if (!subtitle) return null; + const timestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp); + return { + kind: "subtitle", + id: link.id, + subtitle, + ...(timestamp ? { timestamp } : {}), + }; +} + +function observeSubagent( + subagent: ClaudeReplaySubagentInput, + parent: ClaudeReplayParentFacts, + convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[], +): SubagentObservation[] { + const link = resolveLink(subagent, parent); + const toolCall = link.toolCallId ? parent.toolCalls.get(link.toolCallId) : undefined; + + const observations: SubagentObservation[] = [declareSubagent(subagent, link, toolCall)]; + const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType); + if (subtitle) observations.push(subtitle); + + for (const entry of subagent.entries) { + const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); + for (const item of convertEntry(entry)) { + observations.push({ + kind: "timeline", + id: link.id, + item, + ...(timestamp ? { timestamp } : {}), + }); + } + } + + // A transcript with no recorded outcome is a subagent that never finished — leave it running + // rather than inventing a completion. + if (link.failed !== null) { + const lastTimestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp); + observations.push({ + kind: "status", + id: link.id, + status: link.failed ? "failed" : "completed", + ...(lastTimestamp ? { timestamp: lastTimestamp } : {}), + }); + } + + return observations; +} + +/** + * Whether this transcript belongs to a child of the session being replayed. + * + * Claude Code writes every descendant into the same `subagents/` directory, not just the session's + * own children, and `spawnDepth` is what separates them: 1 is a direct child, 2+ was spawned by + * another subagent. A grandchild's `toolUseId` names a Task call made inside its parent's session, + * so nothing in this transcript can resolve it — surveyed across recorded sessions, every depth-1 + * id matched a Task tool_use block in the parent transcript and no deeper id did. Replaying them + * anyway adds rows the live stream never showed, each with no Task card and no recoverable + * outcome, which leaves them running forever. One session recorded 10 subagents live and would + * replay 22. + * + * Absent depth means a session recorded before the field existed: keep the subagent rather than + * lose it, matching how every other missing field here is treated. + */ +function isDirectChild(subagent: ClaudeReplaySubagentInput): boolean { + const depth = subagent.meta?.spawnDepth; + return depth === undefined || depth <= 1; +} + +export function observeReplaySubagents(input: { + subagents: readonly ClaudeReplaySubagentInput[]; + parent: ClaudeReplayParentFacts; + convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[]; +}): SubagentObservation[] { + const observations: SubagentObservation[] = []; + for (const subagent of input.subagents) { + if (!isDirectChild(subagent)) continue; + observations.push(...observeSubagent(subagent, input.parent, input.convertEntry)); + } + return observations; +} diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 00fed1ac89..39b4d10b4a 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -479,7 +479,9 @@ export class OmpHarness { .map(([callId]) => callId); } - subagentUpserts(): Array<{ id: string; status: string }> { + // `status` is optional on the upsert event — an upsert may report only model or usage. OMP + // always sets one, so this stays a plain string for assertions. + subagentUpserts(): Array<{ id: string; status: string | undefined }> { return this.events.flatMap((event) => event.type === "provider_subagent" && event.event.type === "upsert" ? [{ id: event.event.id, status: event.event.status }] From 800bd2bef1dbb34568fd4efc8c6972cf10e2dd38 Mon Sep 17 00:00:00 2001 From: nikuscs Date: Fri, 31 Jul 2026 18:34:42 +0100 Subject: [PATCH 36/46] feat: add host-local custom project icons (#2335) * feat(projects): add local icons and colors * refactor(projects): simplify appearance handling * fix(projects): serialize appearance saves * test(app): update project appearance expectations * test: fix project appearance CI expectations * refactor(projects): organize appearance code * fix(server): adopt main's reconcile model in project appearance updates * fix(projects): preserve host-local appearance targets * fix(desktop): restore Linux development startup Ubuntu can block Electron's unprivileged user-namespace fallback while the npm-installed sandbox helper is not SUID. Disable the Chromium process sandbox only in the Linux development runner so packaged desktop policy remains unchanged. * feat(projects): add custom project icons Keep URL acquisition in the client so the host daemon only validates uploaded bytes. Project name and icon changes share one adaptive edit sheet. * refactor(settings): scope projects by host --------- Co-authored-by: Mohamed Boudra --- .oxlintrc.json | 5 +- docs/architecture.md | 1 + docs/data-model.md | 32 +- packages/app/e2e/helpers/project-grouping.ts | 41 +- packages/app/e2e/helpers/project-settings.ts | 89 +++- packages/app/e2e/helpers/settings.ts | 25 +- packages/app/e2e/projects-settings.spec.ts | 103 +++- .../app/e2e/sidebar-project-grouping.spec.ts | 14 +- packages/app/src/app/_layout.tsx | 4 +- .../[serverId]/projects}/[projectId].tsx | 9 +- .../hosts/[serverId]/projects/index.tsx | 20 + .../app/src/app/settings/projects/index.tsx | 7 - .../app/src/components/project-edit-sheet.tsx | 301 ++++++++++++ .../src/components/sidebar-workspace-list.tsx | 6 +- .../src/components/workspace-setup-dialog.tsx | 4 +- .../worktree-setup-callout-policy.test.ts | 2 +- .../app/src/hooks/use-open-project.test.ts | 2 + .../app/src/hooks/use-project-icon-query.ts | 49 -- packages/app/src/i18n/resources/ar.ts | 24 +- packages/app/src/i18n/resources/en.ts | 24 +- packages/app/src/i18n/resources/es.ts | 24 +- packages/app/src/i18n/resources/fr.ts | 25 +- packages/app/src/i18n/resources/ja.ts | 25 +- packages/app/src/i18n/resources/pt-BR.ts | 24 +- packages/app/src/i18n/resources/ru.ts | 25 +- packages/app/src/i18n/resources/zh-CN.ts | 24 +- packages/app/src/projects/edit-form.test.ts | 153 ++++++ packages/app/src/projects/edit-form.ts | 167 +++++++ .../app/src/projects/host-project-model.ts | 1 + packages/app/src/projects/icons.test.ts | 23 + packages/app/src/projects/icons.ts | 157 ++++++ .../src/projects/import-project-icon.test.ts | 46 ++ .../app/src/projects/import-project-icon.ts | 89 ++++ packages/app/src/projects/project-icons.ts | 94 ---- .../app/src/projects/workspace-structure.ts | 2 + .../directory-sync/workspace-replica.ts | 1 + .../app/src/runtime/replica-cache/index.ts | 1 + .../app/src/screens/new-workspace-screen.tsx | 16 +- .../project-settings-host-selection.test.ts | 30 -- .../project-settings-host-selection.ts | 40 -- .../src/screens/project-settings-screen.tsx | 462 ++++-------------- .../app/src/screens/projects-screen.test.tsx | 59 +-- packages/app/src/screens/projects-screen.tsx | 82 ++-- packages/app/src/screens/settings-screen.tsx | 115 ++--- .../session-store-hooks/selectors.test.ts | 2 + packages/app/src/stores/session-store.ts | 4 + packages/app/src/utils/host-routes.test.ts | 7 +- packages/app/src/utils/host-routes.ts | 11 +- packages/app/src/utils/projects.ts | 4 + .../src/utils/sidebar-project-row-model.ts | 9 +- packages/client/src/daemon-client.ts | 24 + packages/desktop/scripts/dev-runner-args.mjs | 7 + .../desktop/scripts/dev-runner-args.test.mjs | 18 + packages/desktop/scripts/dev-runner.mjs | 4 +- packages/protocol/src/messages.test.ts | 13 + packages/protocol/src/messages.ts | 56 +++ packages/server/src/server/session.test.ts | 2 + packages/server/src/server/session.ts | 76 +++ .../src/server/session.workspaces.test.ts | 93 ++++ .../server/src/server/websocket-server.ts | 4 + .../server/src/server/wire-compat.test.ts | 1 + .../src/server/workspace-directory.test.ts | 1 + .../server/src/server/workspace-directory.ts | 1 + .../server/src/server/workspace-registry.ts | 22 + .../src/utils/project-custom-icon.test.ts | 137 ++++++ .../server/src/utils/project-custom-icon.ts | 123 +++++ packages/server/src/utils/project-icon.ts | 4 +- 67 files changed, 2158 insertions(+), 912 deletions(-) rename packages/app/src/app/settings/{projects/[serverId] => hosts/[serverId]/projects}/[projectId].tsx (68%) create mode 100644 packages/app/src/app/settings/hosts/[serverId]/projects/index.tsx delete mode 100644 packages/app/src/app/settings/projects/index.tsx create mode 100644 packages/app/src/components/project-edit-sheet.tsx delete mode 100644 packages/app/src/hooks/use-project-icon-query.ts create mode 100644 packages/app/src/projects/edit-form.test.ts create mode 100644 packages/app/src/projects/edit-form.ts create mode 100644 packages/app/src/projects/icons.test.ts create mode 100644 packages/app/src/projects/icons.ts create mode 100644 packages/app/src/projects/import-project-icon.test.ts create mode 100644 packages/app/src/projects/import-project-icon.ts delete mode 100644 packages/app/src/projects/project-icons.ts delete mode 100644 packages/app/src/screens/project-settings-host-selection.test.ts delete mode 100644 packages/app/src/screens/project-settings-host-selection.ts create mode 100644 packages/desktop/scripts/dev-runner-args.mjs create mode 100644 packages/desktop/scripts/dev-runner-args.test.mjs create mode 100644 packages/server/src/utils/project-custom-icon.test.ts create mode 100644 packages/server/src/utils/project-custom-icon.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index bdcab1dc5b..6948690cad 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -130,7 +130,7 @@ ] } }, - // Raw query burn-down: 35 files total; the 29 files below still enforce the useUnistyles ban. + // Raw query burn-down: 34 files total; the 28 files below still enforce the useUnistyles ban. { "files": [ "packages/app/src/assistant-file-links/use-file-link.ts", @@ -154,10 +154,9 @@ "packages/app/src/hooks/use-is-local-daemon.ts", "packages/app/src/hooks/use-keyboard-shortcut-overrides.ts", "packages/app/src/hooks/use-preferred-editor.ts", - "packages/app/src/hooks/use-project-icon-query.ts", "packages/app/src/hooks/use-settings/index.ts", "packages/app/src/panels/terminal-panel.tsx", - "packages/app/src/projects/project-icons.ts", + "packages/app/src/projects/icons.ts", "packages/app/src/provider-usage/use-provider-usage.ts", "packages/app/src/screens/project-settings-screen.tsx", "packages/app/src/screens/workspace/use-workspace-checkout-status.ts", diff --git a/docs/architecture.md b/docs/architecture.md index 62f80b63b1..ccd497be92 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -374,6 +374,7 @@ $PASEO_HOME/ ├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows ├── projects/projects.json # Project registry ├── projects/workspaces.json # Workspace registry +├── projects/icons/ # Custom project icon images ├── chat/ # Chat rooms ├── schedules/ # Scheduled-agent definitions and runs ├── loops/ # Loop runs and logs diff --git a/docs/data-model.md b/docs/data-model.md index 318e4835d6..2435482365 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -60,7 +60,8 @@ $PASEO_HOME/ │ └── loops.json # All loop records ├── projects/ │ ├── projects.json # Project registry -│ └── workspaces.json # Workspace registry +│ ├── workspaces.json # Workspace registry +│ └── icons/ # Host-local custom project icon images ├── runtime/ │ └── managed-processes/ │ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap @@ -454,17 +455,24 @@ Single file containing an array of all loop records. Writes are direct (not atom Array of project records. -| Field | Type | Description | -| ------------- | --------------------------- | -------------------------------------------------------------------------------- | -| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs | -| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values | -| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed | -| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key | -| `displayName` | `string` | Selected-root basename, stable across remote and Git changes | -| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | +| Field | Type | Description | +| -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `projectId` | `string` | Host-local primary key; new records use opaque `prj_<16 hex>` IDs | +| `projectKey` | `string \| null` | Persisted opaque cross-host grouping key; reconciliation backfills absent values | +| `rootPath` | `string` | Exact lexically normalized selected root; never realpathed | +| `kind` | `"git" \| "non_git"` | Mutable Git observation about `rootPath`, never a membership key | +| `displayName` | `string` | Selected-root basename, stable across remote and Git changes | +| `customName` | `string \| null` | User-set override layered over `displayName`. Null means "use the derived name". | +| `customIconRevision` | `string \| null` | Identifies the host-local custom icon stored under `projects/icons/`. Null means the icon is discovered by scanning the project directory. | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable | + +Uploading a file and pasting a website or image URL are two ways of _acquiring_ the same custom +icon. The client fetches URL imports and sends their bytes through the upload RPC. The daemon never +receives or fetches the URL; it validates the uploaded bytes, stores them, and records a new +`customIconRevision`. Going back to automatic deletes the stored image, as does removing the +project. Active exact roots are idempotent using lexical platform-equivalence semantics. Existing legacy remote-shaped and path-shaped IDs remain readable, including duplicate roots; reconciliation never diff --git a/packages/app/e2e/helpers/project-grouping.ts b/packages/app/e2e/helpers/project-grouping.ts index 61e995ff16..fc56d4bf9a 100644 --- a/packages/app/e2e/helpers/project-grouping.ts +++ b/packages/app/e2e/helpers/project-grouping.ts @@ -2,7 +2,9 @@ import { expect, type Locator, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts"; import { openProjectSettings } from "./project-settings"; +import { selectSettingsHost } from "./settings"; import { waitForSidebarHydration } from "./workspace-ui"; +import { buildProjectsSettingsRoute } from "@/utils/host-routes"; const PROJECT_VISIBILITY_TIMEOUT = 30_000; @@ -98,34 +100,37 @@ export async function expectProjectWorkspaceCountForHost( } export async function renameProject(page: Page, name: string): Promise { - await page.getByRole("button", { name: "Rename project", exact: true }).click(); + await page.getByRole("button", { name: "Edit project", exact: true }).click(); await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name); - await page.getByRole("button", { name: "Save project name", exact: true }).click(); - await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({ + await page.getByRole("button", { name: "Save changes", exact: true }).click(); + await expect(page.getByText("Project updated", { exact: true })).toBeVisible({ timeout: PROJECT_VISIBILITY_TIMEOUT, }); } -export async function openGroupedProjectSettings(page: Page, projectName: string): Promise { +export async function openGroupedProjectSettings( + page: Page, + input: { serverId: string; projectName: string }, +): Promise { await page.getByRole("button", { name: "Settings", exact: true }).click(); await expect(page).toHaveURL(/\/settings\/general$/); - await page.getByRole("button", { name: "Projects", exact: true }).click(); - await expect(page).toHaveURL(/\/settings\/projects$/); - await openProjectSettings(page, projectName); + await selectSettingsHost(page, input.serverId); + await page.locator('[data-testid="settings-host-section-projects"]:visible').click(); + await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/projects$/); + await openProjectSettings(page, input.projectName); } -export async function switchProjectSettingsHost(page: Page, hostName: string): Promise { - const projectSettings = page.getByRole("main"); - await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click(); - await page.getByRole("button", { name: hostName, exact: true }).click(); - await expect( - projectSettings.getByRole("button", { name: "Switch host", exact: true }), - ).toContainText(hostName); +export async function openProjectsForSettingsHost( + page: Page, + input: { serverId: string; projectName: string }, +): Promise { + await selectSettingsHost(page, input.serverId); + await expect(page).toHaveURL(buildProjectsSettingsRoute(input.serverId)); + await openProjectSettings(page, input.projectName); } export async function expectProjectSettingsName(page: Page, projectName: string): Promise { - await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText( - projectName, - { timeout: PROJECT_VISIBILITY_TIMEOUT }, - ); + await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible({ + timeout: PROJECT_VISIBILITY_TIMEOUT, + }); } diff --git a/packages/app/e2e/helpers/project-settings.ts b/packages/app/e2e/helpers/project-settings.ts index eed5bf9b53..703a0fa83b 100644 --- a/packages/app/e2e/helpers/project-settings.ts +++ b/packages/app/e2e/helpers/project-settings.ts @@ -4,6 +4,8 @@ import { expect, type Page } from "@playwright/test"; import type { WebSocketRoute } from "@playwright/test"; import { gotoAppShell, openSettings } from "./app"; import { daemonWsRoutePattern } from "./daemon-port"; +import { getServerId } from "./server-id"; +import { buildProjectsSettingsRoute } from "@/utils/host-routes"; type WebSocketMessage = string | Buffer; @@ -33,8 +35,8 @@ function getSessionMessage(message: WebSocketMessage): Record | export async function openProjects(page: Page): Promise { await gotoAppShell(page); await openSettings(page); - await page.getByTestId("settings-projects").click(); - await expect(page).toHaveURL(/\/settings\/projects$/); + await page.getByRole("button", { name: "Projects", exact: true }).click(); + await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId())); } export async function openProjectSettings(page: Page, projectName: string): Promise { @@ -48,6 +50,21 @@ export async function navigateToProjectSettings(page: Page, projectName: string) await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click(); } +export async function returnToProjectsList(page: Page): Promise { + await page.getByRole("button", { name: "Back to projects", exact: true }).click(); + await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId())); +} + +export async function expectProjectSettingsHistoryRoundTrip( + page: Page, + projectName: string, +): Promise { + await page.goBack(); + await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId())); + await page.goForward(); + await expectProjectTitle(page, projectName); +} + // --- Form interactions --- export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise { @@ -57,7 +74,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr } export async function clickSaveProjectSettings(page: Page): Promise { - await page.getByRole("button", { name: "Save" }).click(); + await page.getByTestId("save-button").click(); } export async function clickRetryProjectSettingsSave(page: Page): Promise { @@ -72,6 +89,61 @@ export async function clickReloadProjectSettings(page: Page): Promise { await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click(); } +// --- Project edit sheet (name + icon) --- + +export async function openProjectEditSheet(page: Page): Promise { + await page.getByRole("button", { name: "Edit project", exact: true }).click(); + await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible(); +} + +export async function fillProjectName(page: Page, name: string): Promise { + await page.getByRole("textbox", { name: "Project name" }).fill(name); +} + +export async function chooseProjectIconImage( + page: Page, + file: { name: string; mimeType: string; buffer: Buffer }, +): Promise { + const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 }); + await page.getByRole("button", { name: "Choose image" }).click(); + await (await chooserPromise).setFiles([file]); + await expect(page.getByText(file.name, { exact: true })).toBeVisible(); +} + +export async function fillProjectIconUrl(page: Page, url: string): Promise { + await page.getByRole("textbox", { name: "Image or website URL" }).fill(url); +} + +export async function useAutomaticProjectIcon(page: Page): Promise { + await page.getByRole("button", { name: "Use automatic" }).click(); +} + +export async function saveProjectEdits(page: Page): Promise { + await page.getByRole("button", { name: "Save changes" }).click(); +} + +export async function expectProjectEditName(page: Page, name: string): Promise { + await expect(page.getByRole("textbox", { name: "Project name" })).toHaveValue(name); +} + +export async function expectProjectEditsSaveDisabled(page: Page): Promise { + await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled(); +} + +export async function expectProjectEditSaved(page: Page): Promise { + await expect(page.getByTestId("app-toast-message")).toHaveText("Project updated"); +} + +// The sheet keeps the user's input on a failed save so the value stays editable. +export async function expectProjectEditFailed(page: Page, detail: string): Promise { + await expect(page.getByText(detail, { exact: true })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible(); +} + +export async function expectProjectTitle(page: Page, projectName: string): Promise { + await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible(); +} + // --- Error-state assertions --- type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed"; @@ -102,7 +174,7 @@ export async function expectWriteFailedCalloutActions(page: Page): Promise } export async function expectSaveButtonDisabled(page: Page): Promise { - await expect(page.getByRole("button", { name: "Save" })).toBeDisabled(); + await expect(page.getByTestId("save-button")).toBeDisabled(); } // --- Form-state assertions --- @@ -121,13 +193,8 @@ export async function expectNoEditableTarget(page: Page): Promise { await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 }); } -// --- Host-section assertions --- - -export async function expectHostIndicatorVisible(page: Page): Promise { - await expect(page.getByTestId("host-indicator")).toBeVisible(); -} - -export async function expectHostPickerHidden(page: Page): Promise { +export async function expectProjectHostContextHidden(page: Page): Promise { + await expect(page.getByTestId("host-indicator")).not.toBeVisible(); await expect(page.getByTestId("host-picker")).not.toBeVisible(); } diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index 2446c74a88..8cf63c9559 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -3,7 +3,6 @@ import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from ". import { getServerId } from "./server-id"; import { expectAppRoute } from "./route-assertions"; import { - buildProjectsSettingsRoute, buildSettingsHostSectionRoute, buildSettingsRoute, buildSettingsSectionRoute, @@ -30,20 +29,21 @@ const SECTION_LABELS = { about: "About", } as const; -export type SettingsSection = keyof typeof SECTION_LABELS | "projects"; +export type SettingsSection = keyof typeof SECTION_LABELS; -type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host"; +type HostSection = + | "projects" + | "connections" + | "agents" + | "workspaces" + | "providers" + | "usage" + | "host"; export async function openSettingsSection(page: Page, section: SettingsSection): Promise { const sidebar = page.getByTestId("settings-sidebar"); await expect(sidebar).toBeVisible(); - if (section === "projects") { - await page.getByTestId("settings-projects").click(); - await expectAppRoute(page, buildProjectsSettingsRoute()); - return; - } - await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click(); await expectAppRoute(page, buildSettingsSectionRoute(section)); } @@ -143,8 +143,8 @@ export async function seedSavedSettingsHosts( } export async function selectSettingsHost(page: Page, serverId: string): Promise { - await page.getByTestId("settings-host-picker").click(); - await page.getByTestId(`settings-host-picker-item-${serverId}`).click(); + await page.locator('[data-testid="settings-host-picker"]:visible').click(); + await page.locator(`[data-testid="settings-host-picker-item-${serverId}"]:visible`).click(); } export async function expectSettingsHostPickerLabel(page: Page, label: string): Promise { @@ -171,7 +171,7 @@ export async function expectSettingsSidebarHidden(page: Page): Promise { export async function expectSettingsSidebarSections( page: Page, - sections: Array>, + sections: SettingsSection[], ): Promise { const sidebar = page.getByTestId("settings-sidebar"); for (const section of sections) { @@ -376,6 +376,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise { await expectProjectConfigSaved(editableProject); }); + test("project navigation stays inside the selected host", async ({ page, editableProject }) => { + await openProjects(page); + await openProjectSettings(page, editableProject.name); + await expectProjectHostContextHidden(page); + await returnToProjectsList(page); + await openProjectSettings(page, editableProject.name); + await expectProjectSettingsHistoryRoundTrip(page, editableProject.name); + }); + test("user edits worktree setup on a non-GitHub remote project", async ({ page, gitlabRemoteProject, @@ -215,6 +241,74 @@ test.describe("Projects settings", () => { await clickSaveProjectSettings(page); await expectProjectConfigSaved(gitlabRemoteProject); }); + + test("user renames a project from the edit sheet", async ({ page, editableProject }) => { + await openProjects(page); + await openProjectSettings(page, editableProject.name); + await openProjectEditSheet(page); + await fillProjectName(page, "Renamed project"); + await saveProjectEdits(page); + + await expectProjectEditSaved(page); + await expectProjectTitle(page, "Renamed project"); + }); + + test("reopening the edit sheet seeds from the saved project", async ({ + page, + editableProject, + }) => { + await openProjects(page); + await openProjectSettings(page, editableProject.name); + await openProjectEditSheet(page); + await fillProjectName(page, "Renamed project"); + await saveProjectEdits(page); + await expectProjectEditSaved(page); + + await openProjectEditSheet(page); + + await expectProjectEditName(page, "Renamed project"); + await expectProjectEditsSaveDisabled(page); + }); + + test("user picks a custom project icon from a file", async ({ page, editableProject }) => { + await openProjects(page); + await openProjectSettings(page, editableProject.name); + await openProjectEditSheet(page); + await chooseProjectIconImage(page, { + name: "logo.png", + mimeType: "image/png", + buffer: PNG_1X1, + }); + await saveProjectEdits(page); + + await expectProjectEditSaved(page); + }); + + test("user sets a project name and icon in one save", async ({ page, editableProject }) => { + await openProjects(page); + await openProjectSettings(page, editableProject.name); + await openProjectEditSheet(page); + await fillProjectName(page, "Both at once"); + await chooseProjectIconImage(page, { + name: "logo.png", + mimeType: "image/png", + buffer: PNG_1X1, + }); + await saveProjectEdits(page); + + await expectProjectEditSaved(page); + await expectProjectTitle(page, "Both at once"); + }); + + test("project edit keeps a rejected icon URL actionable", async ({ page, editableProject }) => { + await openProjects(page); + await openProjectSettings(page, editableProject.name); + await openProjectEditSheet(page); + await fillProjectIconUrl(page, "file:///etc/passwd"); + await saveProjectEdits(page); + + await expectProjectEditFailed(page, "URL must use HTTP or HTTPS without credentials"); + }); }); test.describe("Projects settings — error UX", () => { @@ -322,15 +416,14 @@ test.describe("Projects settings — error UX", () => { await expectNoEditableTarget(page); }); - test("single-host project renders static host indicator, not a picker chip", async ({ + test("project detail does not render a second host selector", async ({ page, editableProject, }) => { await openProjects(page); await openProjectSettings(page, editableProject.name); - await expectHostIndicatorVisible(page); - await expectHostPickerHidden(page); + await expectProjectHostContextHidden(page); }); test("script removal via kebab menu removes the row from the form", async ({ diff --git a/packages/app/e2e/sidebar-project-grouping.spec.ts b/packages/app/e2e/sidebar-project-grouping.spec.ts index 7771862947..d2e72bfa94 100644 --- a/packages/app/e2e/sidebar-project-grouping.spec.ts +++ b/packages/app/e2e/sidebar-project-grouping.spec.ts @@ -9,11 +9,11 @@ import { expectProjectWorkspaceCountForHost, expectSeparateProjects, openGroupedProjectSettings, + openProjectsForSettingsHost, openProjectDirectory, openProjectDirectoryWithHosts, renameProject, selectWorkspaceHost, - switchProjectSettingsHost, } from "./helpers/project-grouping"; import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon"; import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client"; @@ -380,10 +380,18 @@ test.describe("Sidebar project grouping", () => { test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => { await openScenario(page, crossHostProject); - await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME); + await openGroupedProjectSettings(page, { + serverId: getServerId(), + projectName: GROUPED_PROJECT_NAME, + }); await renameProject(page, "Primary-only project name"); await expectProjectSettingsName(page, "Primary-only project name"); - await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL); + const secondaryHost = crossHostProject.hosts[0]; + if (!secondaryHost) throw new Error("Expected a secondary host"); + await openProjectsForSettingsHost(page, { + serverId: secondaryHost.serverId, + projectName: GROUPED_PROJECT_NAME, + }); await expectProjectSettingsName(page, GROUPED_PROJECT_NAME); }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 037592558c..5b26b0d158 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -882,8 +882,6 @@ function RootStack() { - - @@ -893,6 +891,8 @@ function RootStack() { + + ); } diff --git a/packages/app/src/app/settings/projects/[serverId]/[projectId].tsx b/packages/app/src/app/settings/hosts/[serverId]/projects/[projectId].tsx similarity index 68% rename from packages/app/src/app/settings/projects/[serverId]/[projectId].tsx rename to packages/app/src/app/settings/hosts/[serverId]/projects/[projectId].tsx index d967bcef47..ac3e35aaf4 100644 --- a/packages/app/src/app/settings/projects/[serverId]/[projectId].tsx +++ b/packages/app/src/app/settings/hosts/[serverId]/projects/[projectId].tsx @@ -1,9 +1,10 @@ import { useLocalSearchParams } from "expo-router"; import { useMemo } from "react"; +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import SettingsScreen from "@/screens/settings-screen"; import { normalizeProjectSettingsRouteId } from "@/utils/host-routes"; -export default function SettingsProjectDetailRoute() { +export default function SettingsHostProjectDetailRoute() { const params = useLocalSearchParams<{ serverId?: string | string[]; projectId?: string | string[]; @@ -15,5 +16,9 @@ export default function SettingsProjectDetailRoute() { [projectId, serverId], ); - return ; + return ( + + + + ); } diff --git a/packages/app/src/app/settings/hosts/[serverId]/projects/index.tsx b/packages/app/src/app/settings/hosts/[serverId]/projects/index.tsx new file mode 100644 index 0000000000..aa8087b9ba --- /dev/null +++ b/packages/app/src/app/settings/hosts/[serverId]/projects/index.tsx @@ -0,0 +1,20 @@ +import { useLocalSearchParams } from "expo-router"; +import { useMemo } from "react"; +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import SettingsScreen from "@/screens/settings-screen"; +import { normalizeProjectSettingsRouteId } from "@/utils/host-routes"; + +export default function SettingsHostProjectsRoute() { + const params = useLocalSearchParams<{ serverId?: string | string[] }>(); + const serverId = normalizeProjectSettingsRouteId(params.serverId); + const view = useMemo( + () => ({ kind: "host" as const, serverId, section: "projects" as const }), + [serverId], + ); + + return ( + + + + ); +} diff --git a/packages/app/src/app/settings/projects/index.tsx b/packages/app/src/app/settings/projects/index.tsx deleted file mode 100644 index e63885d87d..0000000000 --- a/packages/app/src/app/settings/projects/index.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import SettingsScreen from "@/screens/settings-screen"; - -const PROJECTS_VIEW = { kind: "projects" as const }; - -export default function SettingsProjectsIndexRoute() { - return ; -} diff --git a/packages/app/src/components/project-edit-sheet.tsx b/packages/app/src/components/project-edit-sheet.tsx new file mode 100644 index 0000000000..f94ebdeeea --- /dev/null +++ b/packages/app/src/components/project-edit-sheet.tsx @@ -0,0 +1,301 @@ +import { Buffer } from "buffer"; +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import { View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { ProjectIconSource } from "@getpaseo/protocol/messages"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { Button } from "@/components/ui/button"; +import type { FieldControlSize } from "@/components/ui/control-geometry"; +import { Field, FormTextInput } from "@/components/ui/form-field"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-context"; +import { useFilePicker } from "@/hooks/use-file-picker"; +import { + openProjectEditForm, + type ProjectEditFormError, + type ProjectEditFormSnapshot, + type ProjectEditSubmission, + type ProjectIconIntent, +} from "@/projects/edit-form"; +import { importProjectIconFromUrl } from "@/projects/import-project-icon"; +import { toErrorMessage } from "@/utils/error-messages"; + +export interface ProjectEditSheetProps { + visible: boolean; + onClose: () => void; + serverId: string; + projectId: string; + projectViewKey: string; + client: DaemonClient; + /** False on hosts that predate custom project icons — the icon field is hidden. */ + supportsCustomIcon: boolean; + snapshot: ProjectEditFormSnapshot; +} + +/** Editing a project is its name and its icon, decided together and saved once. */ +export function ProjectEditSheet({ + visible, + onClose, + serverId, + projectId, + projectViewKey, + client, + supportsCustomIcon, + snapshot, +}: ProjectEditSheetProps) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const toast = useToast(); + const { pickFiles } = useFilePicker(); + const size: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; + const [form] = useState(() => openProjectEditForm(snapshot)); + const state = useSyncExternalStore(form.subscribe, form.getState, form.getState); + + const mutation = useMutation({ + mutationFn: (submission: ProjectEditSubmission) => + submitProjectEdit({ client, projectId, submission }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["projects"] }); + queryClient.invalidateQueries({ queryKey: ["projectIcon", serverId] }); + toast.show(t("settings.project.edit.savedToast"), { variant: "success" }); + onClose(); + }, + onError: (error) => form.setError(toFormError(error)), + }); + const isSaving = mutation.isPending; + + const handleClose = useCallback(() => { + if (isSaving) return; + onClose(); + }, [isSaving, onClose]); + + const handleSubmit = useCallback(() => { + mutation.mutate(state.submission); + }, [mutation, state.submission]); + + const chooseImage = useCallback(async () => { + const file = (await pickFiles())?.[0]; + if (!file) return; + form.setPickedImage({ + fileName: file.fileName, + mimeType: file.mimeType, + data: Buffer.from(file.bytes).toString("base64"), + }); + }, [form, pickFiles]); + + const handleChooseImage = useCallback(() => { + void chooseImage(); + }, [chooseImage]); + + const header = useMemo(() => ({ title: t("settings.project.edit.title") }), [t]); + + const footer = useMemo( + () => ( + + + + + ), + [handleClose, handleSubmit, isSaving, state.canSubmit, t], + ); + + return ( + + + + + + {supportsCustomIcon ? ( + + + + + + {state.canUseAutomatic ? ( + + ) : null} + + + + + ) : null} + + ); +} + +function projectInitial(projectName: string): string { + return projectName.trim().charAt(0).toUpperCase() || "?"; +} + +class ProjectEditStepError extends Error { + constructor( + readonly scope: ProjectEditFormError["scope"], + cause: unknown, + ) { + super(toErrorMessage(cause)); + this.name = "ProjectEditStepError"; + } +} + +async function step(scope: ProjectEditFormError["scope"], run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + throw new ProjectEditStepError(scope, error); + } +} + +/** + * One submit, up to two RPCs. Icon bytes are acquired before either RPC runs + * because that is the step most likely to fail; past that point both RPCs take + * the same inputs on a retry, so a failure between them is safe to submit again. + */ +async function submitProjectEdit(input: { + client: DaemonClient; + projectId: string; + submission: ProjectEditSubmission; +}): Promise { + const { client, projectId, submission } = input; + const { rename, icon } = submission; + + const source = icon ? await step("icon", () => acquireIconSource(icon)) : null; + if (rename) { + await step("name", () => client.renameProject(projectId, rename.customName)); + } + if (source) { + await step("icon", () => client.setProjectIcon(projectId, source)); + } +} + +function acquireIconSource(intent: ProjectIconIntent): Promise { + if (intent.type === "url") return importProjectIconFromUrl(intent.url); + return Promise.resolve(intent); +} + +function toFormError(error: unknown): ProjectEditFormError { + if (error instanceof ProjectEditStepError) { + return { scope: error.scope, message: error.message }; + } + return { scope: "name", message: toErrorMessage(error) }; +} + +const styles = StyleSheet.create((theme) => ({ + iconField: { + gap: theme.spacing[2], + }, + iconRow: { + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: theme.spacing[2], + }, + preview: { + width: 40, + height: 40, + borderRadius: theme.borderRadius.md, + }, + previewFallback: { + width: 40, + height: 40, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + }, + previewText: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, + footer: { + flex: 1, + flexDirection: "row", + justifyContent: "flex-end", + gap: theme.spacing[2], + }, + footerButton: { + minWidth: 112, + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 28f94e8230..8f6f0ef348 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -57,7 +57,7 @@ import { import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useHostFeatureMap } from "@/runtime/host-features"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; +import { useProjectIcons } from "@/projects/icons"; import { buildNewWorkspaceRoute, buildProjectSettingsRoute, @@ -1958,7 +1958,7 @@ export function SidebarWorkspaceList({ () => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []), [groupMode, projects], ); - const statusProjectIconByProjectViewKey = useProjectIconDataByProjectViewKey({ + const statusProjectIconByProjectViewKey = useProjectIcons({ projects: statusProjectIconTargets, }); @@ -2127,7 +2127,7 @@ function ProjectModeList({ [parentGestureRef], ); - const projectIconByProjectViewKey = useProjectIconDataByProjectViewKey({ + const projectIconByProjectViewKey = useProjectIcons({ projects: projectIconTargets, }); diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index e26cfedbc7..346dfc8903 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -8,7 +8,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { Composer } from "@/composer"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; -import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; +import { useProjectIcon } from "@/projects/icons"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; @@ -195,7 +195,7 @@ export function WorkspaceSetupDialog() { throw new Error(t("workspaceSetup.errors.composerStateRequired")); } - const { icon: projectIcon } = useProjectIconQuery({ + const { icon: projectIcon } = useProjectIcon({ serverId, cwd: sourceDirectory, }); diff --git a/packages/app/src/components/worktree-setup-callout-policy.test.ts b/packages/app/src/components/worktree-setup-callout-policy.test.ts index 2cd7476d62..edfbc21efd 100644 --- a/packages/app/src/components/worktree-setup-callout-policy.test.ts +++ b/packages/app/src/components/worktree-setup-callout-policy.test.ts @@ -51,7 +51,7 @@ describe("buildWorktreeSetupCalloutPolicy", () => { }), ).toMatchObject({ id: "worktree-setup-missing:server-1:prj_local", - projectSettingsRoute: "/settings/projects/server-1/prj_local", + projectSettingsRoute: "/settings/hosts/server-1/projects/prj_local", testID: "worktree-setup-callout-prj_local", }); }); diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index e502f3b374..d01602354d 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -96,6 +96,7 @@ describe("openProjectDirectly", () => { projectKey: null, projectDisplayName: "project", projectCustomName: null, + projectCustomIconRevision: null, projectKind: "git", projectRootPath: PROJECT_PATH, }, @@ -193,6 +194,7 @@ describe("cloneGithubProjectDirectly", () => { ...projectPayload, projectCustomName: null, projectKey: null, + projectCustomIconRevision: null, }, }, ]); diff --git a/packages/app/src/hooks/use-project-icon-query.ts b/packages/app/src/hooks/use-project-icon-query.ts deleted file mode 100644 index 7b3ee58922..0000000000 --- a/packages/app/src/hooks/use-project-icon-query.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import type { ProjectIcon } from "@getpaseo/protocol/messages"; - -export function projectIconQueryKey(serverId: string, cwd: string) { - return ["projectIcon", serverId, cwd] as const; -} - -export function projectIconToDataUri(icon: ProjectIcon | null): string | null { - if (!icon) { - return null; - } - return `data:${icon.mimeType};base64,${icon.data}`; -} - -interface UseProjectIconQueryOptions { - serverId: string; - cwd: string; -} - -export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) { - const { t } = useTranslation(); - const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); - - const query = useQuery({ - queryKey: projectIconQueryKey(serverId, cwd), - queryFn: async (): Promise => { - if (!client) { - throw new Error(t("common.errors.daemonClientUnavailable")); - } - const result = await client.requestProjectIcon(cwd); - return result.icon; - }, - enabled: !!client && isConnected && !!cwd, - staleTime: Infinity, - gcTime: 1000 * 60 * 60, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }); - - return { - icon: query.data ?? null, - isLoading: query.isLoading, - isError: query.isError, - }; -} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 4a9ffe67fc..28a8dcbeba 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1621,6 +1621,7 @@ export const ar: TranslationResources = { vimHint: "تنطبق على ملفات المصدر في الويب وسطح المكتب.", }, hostSections: { + projects: "المشاريع", connections: "اتصالات", agents: "Agents", workspaces: "Workspaces", @@ -2121,24 +2122,23 @@ export const ar: TranslationResources = { }, }, project: { - noEditableTarget: "ليس لدينا نسخة قابلة للتحرير من هذا المشروع على أي مضيف متصل.", + noEditableTarget: "هذا المشروع غير متاح للتحرير على هذا المضيف.", backToProjects: "العودة إلى المشاريع", - switchHost: "تبديل المضيف", - rename: { - renamedToast: "تمت إعادة تسمية المشروع", - errorFallback: "تعذرت إعادة تسمية المشروع", - renameLabel: "إعادة تسمية المشروع", - resetLabel: "إعادة تعيين اسم المشروع إلى الافتراضي", - projectNameLabel: "اسم المشروع", - saveLabel: "احفظ اسم المشروع", - cancelLabel: "إلغاء إعادة التسمية", - reset: "إعادة ضبط", + edit: { + title: "تعديل المشروع", + name: "الاسم", + nameLabel: "اسم المشروع", + icon: "الأيقونة", + chooseImage: "اختيار صورة", + useAutomatic: "استخدام التلقائي", + imageUrl: "رابط صورة أو موقع", + save: "حفظ التغييرات", + savedToast: "تم تحديث المشروع", }, readFailures: { invalidTitle: "تعذر تحليل paseo.json", invalidDescription: "قم بإصلاح الملف على القرص، ثم أعد تحميله.", missingTitle: "هذا المضيف ليس لديه هذا المشروع", - missingWithHosts: "قم بالتبديل إلى مضيف آخر أعلاه، أو أعد التحميل.", missingSingleHost: "المضيف المحدد ليس لديه سجل لهذا المشروع.", transportTitle: "تعذر تحميل paseo.json", transportFallback: "المضيف لم يستجب.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a5aef1b400..42d620d85c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1632,6 +1632,7 @@ export const en = { vimHint: "Applies to source files on web and desktop.", }, hostSections: { + projects: "Projects", connections: "Connections", agents: "Agents", workspaces: "Workspaces", @@ -2133,24 +2134,23 @@ export const en = { }, }, project: { - noEditableTarget: "We don't have an editable copy of this project on any connected host.", + noEditableTarget: "This project isn't editable on this host.", backToProjects: "Back to projects", - switchHost: "Switch host", - rename: { - renamedToast: "Project renamed", - errorFallback: "Couldn't rename project", - renameLabel: "Rename project", - resetLabel: "Reset project name to default", - projectNameLabel: "Project name", - saveLabel: "Save project name", - cancelLabel: "Cancel renaming", - reset: "Reset", + edit: { + title: "Edit project", + name: "Name", + nameLabel: "Project name", + icon: "Icon", + chooseImage: "Choose image", + useAutomatic: "Use automatic", + imageUrl: "Image or website URL", + save: "Save changes", + savedToast: "Project updated", }, readFailures: { invalidTitle: "paseo.json couldn't be parsed", invalidDescription: "Fix the file on disk, then reload.", missingTitle: "This host doesn't have this project", - missingWithHosts: "Switch to another host above, or reload.", missingSingleHost: "The selected host has no record of this project.", transportTitle: "Couldn't load paseo.json", transportFallback: "The host didn't respond.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index eee5987893..b61d7be9ae 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1664,6 +1664,7 @@ export const es: TranslationResources = { vimHint: "Se aplica a archivos fuente en web y escritorio.", }, hostSections: { + projects: "Proyectos", connections: "Conexiones", agents: "Agents", workspaces: "Workspaces", @@ -2173,24 +2174,23 @@ export const es: TranslationResources = { }, }, project: { - noEditableTarget: "No tenemos una copia editable de este proyecto en ningún host conectado.", + noEditableTarget: "Este proyecto no se puede editar en este host.", backToProjects: "Volver a proyectos", - switchHost: "Cambiar de anfitrión", - rename: { - renamedToast: "Proyecto renombrado", - errorFallback: "No se pudo cambiar el nombre del proyecto", - renameLabel: "Cambiar nombre del proyecto", - resetLabel: "Restablecer el nombre del proyecto al valor predeterminado", - projectNameLabel: "Nombre del proyecto", - saveLabel: "Guardar nombre del proyecto", - cancelLabel: "Cancelar cambio de nombre", - reset: "Reiniciar", + edit: { + title: "Editar proyecto", + name: "Nombre", + nameLabel: "Nombre del proyecto", + icon: "Icono", + chooseImage: "Elegir imagen", + useAutomatic: "Usar automático", + imageUrl: "URL de imagen o sitio web", + save: "Guardar cambios", + savedToast: "Proyecto actualizado", }, readFailures: { invalidTitle: "paseo.json no se pudo analizar", invalidDescription: "Fije el archivo en el disco y luego vuelva a cargarlo.", missingTitle: "Este anfitrión no tiene este proyecto", - missingWithHosts: "Cambie a otro host de arriba o vuelva a cargar.", missingSingleHost: "El anfitrión seleccionado no tiene registro de este proyecto.", transportTitle: "No se pudo cargar paseo.json", transportFallback: "El anfitrión no respondió.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a015a9156d..c032f4d962 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1668,6 +1668,7 @@ export const fr: TranslationResources = { vimHint: "S’applique aux fichiers source sur le web et le bureau.", }, hostSections: { + projects: "Projets", connections: "Relations", agents: "Agents", workspaces: "Workspaces", @@ -2177,25 +2178,23 @@ export const fr: TranslationResources = { }, }, project: { - noEditableTarget: - "Nous n'avons pas de copie modifiable de ce projet sur aucun hôte connecté.", + noEditableTarget: "Ce projet n'est pas modifiable sur cet hôte.", backToProjects: "Retour aux projets", - switchHost: "Changer d'hôte", - rename: { - renamedToast: "Projet renommé", - errorFallback: "Impossible de renommer le projet", - renameLabel: "Renommer le projet", - resetLabel: "Réinitialiser le nom du projet par défaut", - projectNameLabel: "Nom du projet", - saveLabel: "Enregistrer le nom du projet", - cancelLabel: "Annuler le changement de nom", - reset: "Réinitialiser", + edit: { + title: "Modifier le projet", + name: "Nom", + nameLabel: "Nom du projet", + icon: "Icône", + chooseImage: "Choisir une image", + useAutomatic: "Utiliser l'automatique", + imageUrl: "URL d'image ou de site", + save: "Enregistrer", + savedToast: "Projet mis à jour", }, readFailures: { invalidTitle: "paseo.json n'a pas pu être analysé", invalidDescription: "Corrigez le fichier sur le disque, puis rechargez.", missingTitle: "Cet hôte n'a pas ce projet", - missingWithHosts: "Basculez vers un autre hôte ci-dessus ou rechargez.", missingSingleHost: "L'hôte sélectionné n'a aucune trace de ce projet.", transportTitle: "Impossible de charger paseo.json", transportFallback: "L'hôte n'a pas répondu.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 54b19efc3f..5f5d3678f1 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1637,6 +1637,7 @@ export const ja: TranslationResources = { vimHint: "Web とデスクトップのソースファイルに適用されます。", }, hostSections: { + projects: "プロジェクト", connections: "接続", agents: "エージェント", workspaces: "ワークスペース", @@ -2143,25 +2144,23 @@ export const ja: TranslationResources = { }, }, project: { - noEditableTarget: - "接続されているホストのどれにもこのプロジェクトの編集可能なコピーがありません。", + noEditableTarget: "このホストではこのプロジェクトを編集できません。", backToProjects: "プロジェクトに戻る", - switchHost: "ホストを切り替え", - rename: { - renamedToast: "プロジェクト名を変更しました", - errorFallback: "プロジェクト名を変更できませんでした", - renameLabel: "プロジェクトの名前を変更", - resetLabel: "プロジェクト名をデフォルトにリセット", - projectNameLabel: "プロジェクト名", - saveLabel: "プロジェクト名を保存", - cancelLabel: "名前変更をキャンセル", - reset: "リセット", + edit: { + title: "プロジェクトを編集", + name: "名前", + nameLabel: "プロジェクト名", + icon: "アイコン", + chooseImage: "画像を選択", + useAutomatic: "自動を使う", + imageUrl: "画像またはサイトのURL", + save: "変更を保存", + savedToast: "プロジェクトを更新しました", }, readFailures: { invalidTitle: "paseo.jsonを解析できませんでした", invalidDescription: "ディスク上のファイルを修正してから再読み込みしてください。", missingTitle: "このホストにはこのプロジェクトがありません", - missingWithHosts: "上で別のホストに切り替えるか、再読み込みしてください。", missingSingleHost: "選択したホストにはこのプロジェクトの記録がありません。", transportTitle: "paseo.jsonを読み込めませんでした", transportFallback: "ホストが応答しませんでした。", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b79f7b9777..cbedae0347 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1650,6 +1650,7 @@ export const ptBR: TranslationResources = { vimHint: "Aplica-se a arquivos-fonte na web e no desktop.", }, hostSections: { + projects: "Projetos", connections: "Conexões", agents: "Agentes", workspaces: "Workspaces", @@ -2158,24 +2159,23 @@ export const ptBR: TranslationResources = { }, }, project: { - noEditableTarget: "Não há uma cópia editável deste projeto em nenhum host conectado.", + noEditableTarget: "Este projeto não pode ser editado neste host.", backToProjects: "Voltar para projetos", - switchHost: "Trocar host", - rename: { - renamedToast: "Projeto renomeado", - errorFallback: "Não foi possível renomear projeto", - renameLabel: "Renomear projeto", - resetLabel: "Redefinir nome do projeto para o padrão", - projectNameLabel: "Nome do projeto", - saveLabel: "Salvar nome do projeto", - cancelLabel: "Cancelar renomeação", - reset: "Redefinir", + edit: { + title: "Editar projeto", + name: "Nome", + nameLabel: "Nome do projeto", + icon: "Ícone", + chooseImage: "Escolher imagem", + useAutomatic: "Usar automático", + imageUrl: "URL de imagem ou site", + save: "Salvar alterações", + savedToast: "Projeto atualizado", }, readFailures: { invalidTitle: "Não foi possível analisar paseo.json", invalidDescription: "Corrija o arquivo no disco e recarregue.", missingTitle: "Este host não tem este projeto", - missingWithHosts: "Troque para outro host acima ou recarregue.", missingSingleHost: "O host selecionado não tem registro deste projeto.", transportTitle: "Não foi possível carregar paseo.json", transportFallback: "O host não respondeu.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5557989c08..6e28e3a6ac 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1655,6 +1655,7 @@ export const ru: TranslationResources = { vimHint: "Применяется к исходным файлам в веб- и настольной версии.", }, hostSections: { + projects: "Проекты", connections: "Соединения", agents: "Agents", workspaces: "Workspaces", @@ -2161,25 +2162,23 @@ export const ru: TranslationResources = { }, }, project: { - noEditableTarget: - "У нас нет редактируемой копии этого проекта ни на одном подключенном хосте.", + noEditableTarget: "Этот проект нельзя редактировать на этом хосте.", backToProjects: "Вернуться к проектам", - switchHost: "Сменить хост", - rename: { - renamedToast: "Проект переименован", - errorFallback: "Не удалось переименовать проект", - renameLabel: "Переименовать проект", - resetLabel: "Сбросить имя проекта по умолчанию", - projectNameLabel: "Название проекта", - saveLabel: "Сохранить название проекта", - cancelLabel: "Отменить переименование", - reset: "Перезагрузить", + edit: { + title: "Изменить проект", + name: "Название", + nameLabel: "Название проекта", + icon: "Значок", + chooseImage: "Выбрать изображение", + useAutomatic: "Использовать автоматический", + imageUrl: "URL изображения или сайта", + save: "Сохранить изменения", + savedToast: "Проект обновлён", }, readFailures: { invalidTitle: "paseo.json не удалось разобрать", invalidDescription: "Исправьте файл на диске, затем перезагрузите.", missingTitle: "У этого хоста нет этого проекта", - missingWithHosts: "Переключитесь на другой хост выше или перезагрузите компьютер.", missingSingleHost: "У выбранного хоста нет записей об этом проекте.", transportTitle: "Не удалось загрузить paseo.json.", transportFallback: "Хозяин не ответил.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d40c499c6b..be91814a6b 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1602,6 +1602,7 @@ export const zhCN: TranslationResources = { vimHint: "适用于网页和桌面端的源文件。", }, hostSections: { + projects: "项目", connections: "连接", agents: "Agents", workspaces: "Workspaces", @@ -2095,24 +2096,23 @@ export const zhCN: TranslationResources = { }, }, project: { - noEditableTarget: "任何已连接 Host 上都没有这个 Project 的可编辑副本。", + noEditableTarget: "此项目无法在这个 Host 上编辑。", backToProjects: "返回 Projects", - switchHost: "切换 Host", - rename: { - renamedToast: "Project 已重命名", - errorFallback: "无法重命名 Project", - renameLabel: "重命名 Project", - resetLabel: "将 Project 名称重置为默认值", - projectNameLabel: "Project 名称", - saveLabel: "保存 Project 名称", - cancelLabel: "取消重命名", - reset: "重置", + edit: { + title: "编辑 Project", + name: "名称", + nameLabel: "Project 名称", + icon: "图标", + chooseImage: "选择图片", + useAutomatic: "使用自动", + imageUrl: "图片或网站 URL", + save: "保存更改", + savedToast: "Project 已更新", }, readFailures: { invalidTitle: "无法解析 paseo.json", invalidDescription: "修复磁盘上的文件,然后重新加载。", missingTitle: "这个 Host 没有这个 Project", - missingWithHosts: "切换到上方其他 Host,或重新加载。", missingSingleHost: "所选 Host 没有这个 Project 的记录。", transportTitle: "无法加载 paseo.json", transportFallback: "Host 没有响应。", diff --git a/packages/app/src/projects/edit-form.test.ts b/packages/app/src/projects/edit-form.test.ts new file mode 100644 index 0000000000..43f2492236 --- /dev/null +++ b/packages/app/src/projects/edit-form.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { openProjectEditForm, type ProjectEditFormSnapshot } from "./edit-form"; + +const derivedName: ProjectEditFormSnapshot = { + projectName: "grumpy-turtle", + projectCustomName: null, + hasCustomIcon: false, + currentIconDataUri: "data:image/png;base64,ZGVyaXZlZA==", +}; + +const customIcon: ProjectEditFormSnapshot = { + projectName: "Turtle", + projectCustomName: "Turtle", + hasCustomIcon: true, + currentIconDataUri: "data:image/png;base64,Y3VzdG9t", +}; + +describe("project edit form model", () => { + it("has nothing to submit until the user changes something", () => { + expect(openProjectEditForm(derivedName).getState()).toMatchObject({ + name: "", + canSubmit: false, + canUseAutomatic: false, + submission: { rename: null, icon: null }, + }); + }); + + it("submits only the name when only the name changed", () => { + const model = openProjectEditForm(derivedName); + + model.setName(" Turtle "); + + expect(model.getState()).toMatchObject({ + canSubmit: true, + submission: { rename: { customName: "Turtle" }, icon: null }, + }); + }); + + it("clears the override when the name is emptied", () => { + const model = openProjectEditForm(customIcon); + + model.setName(" "); + + expect(model.getState().submission.rename).toEqual({ customName: null }); + }); + + it("treats a retyped identical name as no change", () => { + const model = openProjectEditForm(customIcon); + + model.setName("Turtle"); + + expect(model.getState()).toMatchObject({ canSubmit: false, submission: { rename: null } }); + }); + + it("previews a picked image and submits it as an upload", () => { + const model = openProjectEditForm(derivedName); + + model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" }); + + expect(model.getState()).toMatchObject({ + previewDataUri: "data:image/png;base64,aW1hZ2U=", + pickedFileName: "logo.png", + canUseAutomatic: true, + submission: { rename: null, icon: { type: "upload", data: "aW1hZ2U=" } }, + }); + }); + + it("keeps a URL client-side and leaves the preview on the current icon", () => { + const model = openProjectEditForm(derivedName); + + model.setImageUrl(" https://example.com "); + + expect(model.getState()).toMatchObject({ + previewDataUri: derivedName.currentIconDataUri, + pickedFileName: null, + submission: { icon: { type: "url", url: "https://example.com" } }, + }); + }); + + it("falls back to the earlier pick when the URL box is emptied", () => { + const model = openProjectEditForm(derivedName); + model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" }); + + model.setImageUrl("https://example.com"); + expect(model.getState().submission.icon).toEqual({ + type: "url", + url: "https://example.com", + }); + + model.setImageUrl(""); + expect(model.getState().submission.icon).toEqual({ type: "upload", data: "aW1hZ2U=" }); + }); + + it("resets a stored custom icon back to automatic", () => { + const model = openProjectEditForm(customIcon); + + expect(model.getState().canUseAutomatic).toBe(true); + + model.useAutomaticIcon(); + + expect(model.getState()).toMatchObject({ + previewDataUri: null, + canUseAutomatic: false, + submission: { rename: null, icon: { type: "automatic" } }, + }); + }); + + it("discards a pick without asking the daemon for an icon it already has", () => { + const model = openProjectEditForm(derivedName); + model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" }); + + model.useAutomaticIcon(); + + expect(model.getState()).toMatchObject({ + previewDataUri: derivedName.currentIconDataUri, + canSubmit: false, + submission: { rename: null, icon: null }, + }); + }); + + it("clears the URL box when the user falls back to automatic", () => { + const model = openProjectEditForm(customIcon); + model.setImageUrl("https://example.com"); + const before = model.getState().urlResetKey; + + model.useAutomaticIcon(); + + expect(model.getState().urlResetKey).not.toBe(before); + }); + + it("submits both fields together when both changed", () => { + const model = openProjectEditForm(customIcon); + + model.setName("Renamed"); + model.setPickedImage({ fileName: "logo.png", mimeType: "image/png", data: "aW1hZ2U=" }); + + expect(model.getState().submission).toEqual({ + rename: { customName: "Renamed" }, + icon: { type: "upload", data: "aW1hZ2U=" }, + }); + }); + + it("drops a submit failure as soon as the user edits again", () => { + const model = openProjectEditForm(derivedName); + model.setError({ scope: "icon", message: "Enter a valid website or image URL" }); + + expect(model.getState().error).toMatchObject({ scope: "icon" }); + + model.setImageUrl("https://example.com"); + + expect(model.getState().error).toBeNull(); + }); +}); diff --git a/packages/app/src/projects/edit-form.ts b/packages/app/src/projects/edit-form.ts new file mode 100644 index 0000000000..4a452427a1 --- /dev/null +++ b/packages/app/src/projects/edit-form.ts @@ -0,0 +1,167 @@ +import type { ProjectIconSource } from "@getpaseo/protocol/messages"; + +/** + * What the user asked the icon to become. A URL is client-side only: the caller + * acquires its bytes and submits the same upload payload a picked file produces. + */ +export type ProjectIconIntent = ProjectIconSource | { type: "url"; url: string }; + +/** The RPCs one submit has to issue. A null field is a field the user left alone. */ +export interface ProjectEditSubmission { + rename: { customName: string | null } | null; + icon: ProjectIconIntent | null; +} + +export interface ProjectEditFormError { + /** Which field the failure belongs under. */ + scope: "name" | "icon"; + message: string; +} + +export interface ProjectEditFormState { + name: string; + /** The image the icon tile renders, null for the derived letter fallback. */ + previewDataUri: string | null; + pickedFileName: string | null; + /** False once the resulting icon is the automatic one — the reset has nothing to do. */ + canUseAutomatic: boolean; + canSubmit: boolean; + error: ProjectEditFormError | null; + /** Bumped only when the model clears the URL box on the user's behalf. */ + urlResetKey: number; + submission: ProjectEditSubmission; +} + +export interface ProjectEditFormModel { + getState: () => ProjectEditFormState; + subscribe: (listener: () => void) => () => void; + setName: (name: string) => void; + setImageUrl: (url: string) => void; + setPickedImage: (image: { fileName: string; mimeType: string; data: string }) => void; + useAutomaticIcon: () => void; + setError: (error: ProjectEditFormError | null) => void; +} + +export interface ProjectEditFormSnapshot { + /** The name on screen today, custom or derived. Seeds the input's placeholder. */ + projectName: string; + /** The override in effect, null while the project uses its derived name. */ + projectCustomName: string | null; + hasCustomIcon: boolean; + /** The icon the project renders today, custom or derived. */ + currentIconDataUri: string | null; +} + +type IconChoice = "unchanged" | "automatic" | "upload" | "url"; + +interface PickedImage { + fileName: string; + mimeType: string; + data: string; +} + +export function openProjectEditForm(snapshot: ProjectEditFormSnapshot): ProjectEditFormModel { + let name = snapshot.projectCustomName ?? ""; + let urlText = ""; + let picked: PickedImage | null = null; + let iconChoice: IconChoice = "unchanged"; + let error: ProjectEditFormError | null = null; + let urlResetKey = 0; + const listeners = new Set<() => void>(); + + function deriveRename(): ProjectEditSubmission["rename"] { + const trimmed = name.trim(); + const customName = trimmed.length === 0 ? null : trimmed; + return customName === snapshot.projectCustomName ? null : { customName }; + } + + function deriveIcon(): ProjectIconIntent | null { + if (iconChoice === "automatic") { + return snapshot.hasCustomIcon ? { type: "automatic" } : null; + } + if (iconChoice === "upload" && picked) { + return { type: "upload", data: picked.data }; + } + if (iconChoice === "url") { + const url = urlText.trim(); + return url.length === 0 ? null : { type: "url", url }; + } + return null; + } + + function derivePreview(): string | null { + // Dropping a custom icon leaves the daemon to re-derive one, so the tile + // falls back to the letter rather than claiming to know the result. + if (iconChoice === "automatic") return null; + if (iconChoice === "upload" && picked) { + return `data:${picked.mimeType};base64,${picked.data}`; + } + return snapshot.currentIconDataUri; + } + + function deriveState(): ProjectEditFormState { + const submission = { rename: deriveRename(), icon: deriveIcon() }; + const willBeCustom = submission.icon + ? submission.icon.type !== "automatic" + : snapshot.hasCustomIcon; + return { + name, + previewDataUri: derivePreview(), + pickedFileName: iconChoice === "upload" ? (picked?.fileName ?? null) : null, + canUseAutomatic: willBeCustom, + canSubmit: Boolean(submission.rename || submission.icon), + error, + urlResetKey, + submission, + }; + } + + let state = deriveState(); + + function publish(): void { + state = deriveState(); + for (const listener of listeners) listener(); + } + + return { + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + setName: (nextName) => { + name = nextName; + error = null; + publish(); + }, + setImageUrl: (url) => { + urlText = url; + // Emptying the box withdraws the URL without discarding an earlier pick. + if (url.trim().length > 0) { + iconChoice = "url"; + } else { + iconChoice = picked ? "upload" : "unchanged"; + } + error = null; + publish(); + }, + setPickedImage: (image) => { + picked = image; + iconChoice = "upload"; + error = null; + publish(); + }, + useAutomaticIcon: () => { + picked = null; + urlText = ""; + urlResetKey += 1; + iconChoice = snapshot.hasCustomIcon ? "automatic" : "unchanged"; + error = null; + publish(); + }, + setError: (nextError) => { + error = nextError; + publish(); + }, + }; +} diff --git a/packages/app/src/projects/host-project-model.ts b/packages/app/src/projects/host-project-model.ts index 525ea647b5..9a0c397ce0 100644 --- a/packages/app/src/projects/host-project-model.ts +++ b/packages/app/src/projects/host-project-model.ts @@ -81,6 +81,7 @@ export function hostProjectFromWorkspace(input: { projectId: input.workspace.projectId, iconWorkingDir, worktreeSupport: canCreate ? "supported" : "unsupported", + customIconRevision: input.workspace.projectCustomIconRevision, }, ], workspaceKeys: [`${input.serverId}:${input.workspace.id}`], diff --git a/packages/app/src/projects/icons.test.ts b/packages/app/src/projects/icons.test.ts new file mode 100644 index 0000000000..48b99df4a4 --- /dev/null +++ b/packages/app/src/projects/icons.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { resolveProjectIconLookup } from "./icons"; + +describe("project icon lookup", () => { + const target = { + projectId: "prj_host_local", + iconWorkingDir: "/projects/paseo", + }; + + it("uses the host-local project ID with custom-icon-capable daemons", () => { + expect(resolveProjectIconLookup(target, true)).toEqual({ + kind: "project", + projectId: "prj_host_local", + }); + }); + + it("uses the project directory with legacy daemons", () => { + expect(resolveProjectIconLookup(target, false)).toEqual({ + kind: "legacy", + cwd: "/projects/paseo", + }); + }); +}); diff --git a/packages/app/src/projects/icons.ts b/packages/app/src/projects/icons.ts new file mode 100644 index 0000000000..f94fdc0f41 --- /dev/null +++ b/packages/app/src/projects/icons.ts @@ -0,0 +1,157 @@ +import { useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import type { ProjectIcon } from "@getpaseo/protocol/messages"; +import { useHostFeatureMap } from "@/runtime/host-features"; +import { + getHostRuntimeStore, + isHostRuntimeConnected, + useHostRuntimeClient, + useHostRuntimeIsConnected, +} from "@/runtime/host-runtime"; + +interface ProjectIconTarget { + serverId: string; + projectViewKey: string; + projectId: string; + iconWorkingDir: string; + customIconRevision?: string | null; +} + +/** + * Daemons without custom-icon support only answer the legacy cwd lookup, which + * still serves their automatically discovered icons. + */ +export function resolveProjectIconLookup( + target: Pick, + supportsCustomIcons: boolean, +): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } { + return supportsCustomIcons + ? { kind: "project", projectId: target.projectId } + : { kind: "legacy", cwd: target.iconWorkingDir }; +} + +function legacyIconQueryKey(serverId: string, cwd: string) { + return ["projectIcon", serverId, "legacy", cwd] as const; +} + +function iconQueryKey(serverId: string, projectId: string, revision: string) { + return ["projectIcon", serverId, projectId, revision] as const; +} + +function iconDataUri(icon: ProjectIcon | null): string | null { + if (!icon) return null; + return `data:${icon.mimeType};base64,${icon.data}`; +} + +function useStableIconData(data: (string | null)[], signature: string): readonly (string | null)[] { + const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null); + if (stableRef.current?.signature !== signature) { + stableRef.current = { signature, data }; + } + return stableRef.current.data; +} + +export function useProjectIcon({ serverId, cwd }: { serverId: string; cwd: string }) { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + + const query = useQuery({ + queryKey: legacyIconQueryKey(serverId, cwd), + queryFn: async (): Promise => { + if (!client) { + throw new Error(t("common.errors.daemonClientUnavailable")); + } + const result = await client.requestProjectIcon(cwd); + return result.icon; + }, + enabled: Boolean(client && isConnected && cwd), + staleTime: Infinity, + gcTime: 1000 * 60 * 60, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + return { + icon: query.data ?? null, + isLoading: query.isLoading, + isError: query.isError, + }; +} + +export function useProjectIcons(input: { + projects: readonly ProjectIconTarget[]; +}): Map { + const serverIds = useMemo( + () => [...new Set(input.projects.map((project) => project.serverId))], + [input.projects], + ); + const supportsCustomIcons = useHostFeatureMap(serverIds, "projectCustomIcon"); + const requests = useMemo(() => { + const unique = new Map(); + for (const project of input.projects) { + if (!project.serverId || !project.projectId || !project.iconWorkingDir.trim()) continue; + unique.set(`${project.serverId}:${project.projectId}`, project); + } + return Array.from(unique.values()); + }, [input.projects]); + + const queries = useQueries({ + queries: requests.map((request) => { + const revision = request.customIconRevision ?? "automatic"; + const lookup = resolveProjectIconLookup( + request, + supportsCustomIcons.get(request.serverId) === true, + ); + return { + queryKey: + lookup.kind === "project" + ? iconQueryKey(request.serverId, lookup.projectId, revision) + : legacyIconQueryKey(request.serverId, lookup.cwd), + queryFn: async () => { + const client = getHostRuntimeStore().getClient(request.serverId); + if (!client) return null; + const result = + lookup.kind === "project" + ? await client.getProjectIcon(lookup.projectId) + : await client.requestProjectIcon(lookup.cwd); + return result.icon; + }, + select: iconDataUri, + enabled: Boolean( + getHostRuntimeStore().getClient(request.serverId) && + isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)), + ), + staleTime: Infinity, + gcTime: 1000 * 60 * 60, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }; + }), + }); + + const signature = queries.map((query) => query.data ?? "").join("\u0000"); + const data = useStableIconData( + queries.map((query) => query.data ?? null), + signature, + ); + + return useMemo(() => { + const byTarget = new Map(); + requests.forEach((request, index) => { + byTarget.set(`${request.serverId}:${request.projectId}`, data[index] ?? null); + }); + + const byProject = new Map(); + for (const project of input.projects) { + byProject.set( + project.projectViewKey, + byTarget.get(`${project.serverId}:${project.projectId}`) ?? null, + ); + } + return byProject; + }, [data, input.projects, requests]); +} diff --git a/packages/app/src/projects/import-project-icon.test.ts b/packages/app/src/projects/import-project-icon.test.ts new file mode 100644 index 0000000000..599f16fd1e --- /dev/null +++ b/packages/app/src/projects/import-project-icon.test.ts @@ -0,0 +1,46 @@ +import { Buffer } from "buffer"; +import { describe, expect, it } from "vitest"; +import { importProjectIconFromUrl } from "./import-project-icon"; + +const IMAGE = new Uint8Array([1, 2, 3, 4]); + +function response(body: BodyInit, contentType: string): Response { + return new Response(body, { headers: { "content-type": contentType } }); +} + +describe("project icon URL import", () => { + it("turns a direct image URL into an upload payload", async () => { + const source = await importProjectIconFromUrl("https://example.com/icon.png", async () => + response(IMAGE.buffer, "image/png"), + ); + + expect(source).toEqual({ type: "upload", data: Buffer.from(IMAGE).toString("base64") }); + }); + + it("fetches a website icon on the client and returns an upload payload", async () => { + const requested: string[] = []; + const source = await importProjectIconFromUrl("https://example.com", async (input) => { + const url = input.toString(); + requested.push(url); + if (url === "https://example.com/") { + return response('', "text/html"); + } + return response(IMAGE.buffer, "image/png"); + }); + + expect(requested).toEqual(["https://example.com/", "https://example.com/brand.png"]); + expect(source.type).toBe("upload"); + }); + + it("rejects non-HTTP URLs without fetching", async () => { + let fetched = false; + + await expect( + importProjectIconFromUrl("file:///etc/passwd", async () => { + fetched = true; + return response(IMAGE.buffer, "image/png"); + }), + ).rejects.toThrow("URL must use HTTP or HTTPS without credentials"); + expect(fetched).toBe(false); + }); +}); diff --git a/packages/app/src/projects/import-project-icon.ts b/packages/app/src/projects/import-project-icon.ts new file mode 100644 index 0000000000..7f09817f06 --- /dev/null +++ b/packages/app/src/projects/import-project-icon.ts @@ -0,0 +1,89 @@ +import { Buffer } from "buffer"; +import type { ProjectIconSource } from "@getpaseo/protocol/messages"; + +const MAX_ICON_BYTES = 512 * 1024; +const MAX_HTML_BYTES = 512 * 1024; +const MAX_ICON_CANDIDATES = 8; + +type UploadSource = Extract; +type FetchIcon = (input: string | URL, init?: RequestInit) => Promise; + +function parseHttpUrl(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Enter a valid website or image URL"); + } + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) { + throw new Error("URL must use HTTP or HTTPS without credentials"); + } + return url; +} + +async function fetchOk(fetchIcon: FetchIcon, url: URL): Promise { + const response = await fetchIcon(url); + if (!response.ok) throw new Error(`Download failed (${response.status})`); + return response; +} + +async function readBytes(response: Response, maxBytes: number): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error("Icon must be 512 KB or smaller"); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > maxBytes) throw new Error("Icon must be 512 KB or smaller"); + return bytes; +} + +function parseTagAttribute(tag: string, name: string): string | null { + const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i")); + return match?.[1]?.trim() || null; +} + +function iconCandidates(html: string, pageUrl: URL): URL[] { + const candidates: URL[] = []; + for (const tag of html.match(/]*>/gi) ?? []) { + const rel = parseTagAttribute(tag, "rel")?.toLowerCase() ?? ""; + const href = parseTagAttribute(tag, "href"); + const type = parseTagAttribute(tag, "type")?.toLowerCase() ?? ""; + if (!href || !rel.includes("icon") || type.includes("svg") || href.endsWith(".svg")) continue; + try { + candidates.push(new URL(href, pageUrl)); + } catch { + // Try the next declared icon and then the conventional fallback. + } + } + candidates.push(new URL("/favicon.ico", pageUrl)); + return candidates.slice(0, MAX_ICON_CANDIDATES); +} + +/** Acquires a URL on the client and collapses it into the normal upload payload. */ +export async function importProjectIconFromUrl( + value: string, + fetchIcon: FetchIcon = fetch, +): Promise { + const url = parseHttpUrl(value.trim()); + const response = await fetchOk(fetchIcon, url); + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + + if (!contentType.includes("text/html")) { + return { type: "upload", data: (await readBytes(response, MAX_ICON_BYTES)).toString("base64") }; + } + + const html = (await readBytes(response, MAX_HTML_BYTES)).toString("utf8"); + let lastError: unknown = new Error("Website does not expose a supported icon"); + for (const candidate of iconCandidates(html, url)) { + try { + const iconResponse = await fetchOk(fetchIcon, candidate); + return { + type: "upload", + data: (await readBytes(iconResponse, MAX_ICON_BYTES)).toString("base64"), + }; + } catch (error) { + lastError = error; + } + } + throw lastError; +} diff --git a/packages/app/src/projects/project-icons.ts b/packages/app/src/projects/project-icons.ts deleted file mode 100644 index af5ee498fb..0000000000 --- a/packages/app/src/projects/project-icons.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useMemo, useRef } from "react"; -import { useQueries } from "@tanstack/react-query"; -import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; -import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query"; - -export interface ProjectIconRequestTarget { - serverId: string; - projectViewKey: string; - iconWorkingDir: string; -} - -function useStableProjectIconData( - data: (string | null)[], - signature: string, -): readonly (string | null)[] { - const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null); - if (stableRef.current?.signature !== signature) { - stableRef.current = { signature, data }; - } - return stableRef.current.data; -} - -export function useProjectIconDataByProjectViewKey(input: { - projects: readonly ProjectIconRequestTarget[]; -}): Map { - const projectIconRequests = useMemo(() => { - const unique = new Map(); - for (const project of input.projects) { - const cwd = project.iconWorkingDir.trim(); - if (!cwd) { - continue; - } - unique.set(`${project.serverId}:${cwd}`, { serverId: project.serverId, cwd }); - } - return Array.from(unique.values()); - }, [input.projects]); - - const projectIconQueries = useQueries({ - queries: projectIconRequests.map((request) => ({ - queryKey: projectIconQueryKey(request.serverId, request.cwd), - queryFn: async () => { - const client = getHostRuntimeStore().getClient(request.serverId); - if (!client) { - return null; - } - const result = await client.requestProjectIcon(request.cwd); - return result.icon; - }, - select: projectIconToDataUri, - enabled: Boolean( - getHostRuntimeStore().getClient(request.serverId) && - isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) && - request.cwd, - ), - staleTime: Infinity, - gcTime: 1000 * 60 * 60, - refetchOnMount: false, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - })), - }); - - const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000"); - const projectIconData = useStableProjectIconData( - projectIconQueries.map((query) => query.data ?? null), - projectIconSignature, - ); - - return useMemo(() => { - const iconByServerAndCwd = new Map(); - for (let index = 0; index < projectIconRequests.length; index += 1) { - const request = projectIconRequests[index]; - if (!request) { - continue; - } - iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null); - } - - const byProject = new Map(); - for (const project of input.projects) { - const cwd = project.iconWorkingDir.trim(); - if (!cwd) { - byProject.set(project.projectViewKey, null); - continue; - } - byProject.set( - project.projectViewKey, - iconByServerAndCwd.get(`${project.serverId}:${cwd}`) ?? null, - ); - } - - return byProject; - }, [input.projects, projectIconData, projectIconRequests]); -} diff --git a/packages/app/src/projects/workspace-structure.ts b/packages/app/src/projects/workspace-structure.ts index 9cd08cc545..6a70f21fb5 100644 --- a/packages/app/src/projects/workspace-structure.ts +++ b/packages/app/src/projects/workspace-structure.ts @@ -6,6 +6,7 @@ export interface WorkspaceStructureHostPlacement { projectId: string; iconWorkingDir: string; worktreeSupport: "supported" | "unsupported" | "unknown"; + customIconRevision?: string | null; } export interface WorkspaceStructureProject { @@ -158,6 +159,7 @@ function addProjectToView(input: { projectId: project.projectId, iconWorkingDir: project.projectRootPath, worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported", + customIconRevision: project.projectCustomIconRevision, }; const draft = byProject.get(viewKey); if (!draft) { diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 38852a08e9..0d940724bb 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -43,6 +43,7 @@ function applyProjectDelta( ...workspace, projectDisplayName: project.projectDisplayName, projectCustomName: project.projectCustomName, + projectCustomIconRevision: project.projectCustomIconRevision, projectRootPath: project.projectRootPath, projectKind: project.projectKind, }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 66c67a0a7b..5158e7d01f 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -196,6 +196,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor projectId: workspace.projectId, projectDisplayName: workspace.projectDisplayName, projectCustomName: workspace.projectCustomName ?? null, + projectCustomIconRevision: workspace.projectCustomIconRevision ?? null, projectRootPath: workspace.projectRootPath, workspaceDirectory: workspace.workspaceDirectory, projectKind: workspace.projectKind, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index eb541c325a..7326f43c80 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -75,7 +75,7 @@ import { useHostProjects, type HostProjectListItem, } from "@/projects/host-projects"; -import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; +import { useProjectIcons } from "@/projects/icons"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; @@ -1578,12 +1578,22 @@ export function NewWorkspaceScreen({ if (!iconWorkingDir) { return []; } - return [{ projectViewKey: project.viewKey, serverId: selectedServerId, iconWorkingDir }]; + const host = project.hosts.find((candidate) => candidate.serverId === selectedServerId); + if (!host) return []; + return [ + { + projectViewKey: project.viewKey, + projectId: host.projectId, + serverId: selectedServerId, + iconWorkingDir, + customIconRevision: host.customIconRevision, + }, + ]; }), [projects, selectedServerId], ); - const projectIconDataByProjectViewKey = useProjectIconDataByProjectViewKey({ + const projectIconDataByProjectViewKey = useProjectIcons({ projects: projectIconTargets, }); const draftKey = buildNewWorkspaceDraftKey(draftId); diff --git a/packages/app/src/screens/project-settings-host-selection.test.ts b/packages/app/src/screens/project-settings-host-selection.test.ts deleted file mode 100644 index 1510f644e8..0000000000 --- a/packages/app/src/screens/project-settings-host-selection.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { expect, test } from "vitest"; -import { resolveProjectSettingsHostTarget } from "./project-settings-host-selection"; - -test("falls back when the selected host is no longer editable", () => { - const hostA = { serverId: "host-a", projectId: "project-a" }; - const hostB = { serverId: "host-b", projectId: "project-b" }; - - expect( - resolveProjectSettingsHostTarget({ - routeKey: "host-a:project-a", - editableHosts: [hostA], - defaultHost: hostA, - selection: { routeKey: "host-a:project-a", ...hostB }, - }), - ).toEqual(hostA); -}); - -test("keeps the selected host while it remains editable", () => { - const hostA = { serverId: "host-a", projectId: "project-a" }; - const hostB = { serverId: "host-b", projectId: "project-b" }; - - expect( - resolveProjectSettingsHostTarget({ - routeKey: "host-a:project-a", - editableHosts: [hostA, hostB], - defaultHost: hostA, - selection: { routeKey: "host-a:project-a", ...hostB }, - }), - ).toEqual(hostB); -}); diff --git a/packages/app/src/screens/project-settings-host-selection.ts b/packages/app/src/screens/project-settings-host-selection.ts deleted file mode 100644 index ed44f4d726..0000000000 --- a/packages/app/src/screens/project-settings-host-selection.ts +++ /dev/null @@ -1,40 +0,0 @@ -export interface ProjectSettingsHostTarget { - serverId: string; - projectId: string; -} - -export interface StoredProjectSettingsHostSelection extends ProjectSettingsHostTarget { - routeKey: string; -} - -interface ResolveProjectSettingsHostTargetInput { - routeKey: string; - editableHosts: readonly ProjectSettingsHostTarget[]; - defaultHost: ProjectSettingsHostTarget | undefined; - selection: StoredProjectSettingsHostSelection; -} - -export function resolveProjectSettingsHostTarget( - input: ResolveProjectSettingsHostTargetInput, -): ProjectSettingsHostTarget | undefined { - const selectedHost = - input.selection.routeKey === input.routeKey - ? getEditableHost(input.editableHosts, input.selection) - : undefined; - return ( - selectedHost ?? - getEditableHost(input.editableHosts, input.defaultHost) ?? - input.editableHosts[0] - ); -} - -function getEditableHost( - editableHosts: readonly ProjectSettingsHostTarget[], - target: ProjectSettingsHostTarget | undefined, -): ProjectSettingsHostTarget | undefined { - if (!target) return undefined; - for (const host of editableHosts) { - if (host.serverId === target.serverId && host.projectId === target.projectId) return host; - } - return undefined; -} diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 7eb03bda8b..6529cab401 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -1,13 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Pressable, Text, TextInput, View } from "react-native"; import { router } from "expo-router"; import { StyleSheet } from "react-native-unistyles"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native"; +import { ArrowLeft, MoreVertical, Pencil, Plus } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; -import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker"; import type { PaseoConfigRaw, PaseoConfigRevision, @@ -26,13 +25,16 @@ import { ExternalLink } from "@/components/ui/external-link"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { ProjectEditSheet } from "@/components/project-edit-sheet"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { useProjects } from "@/hooks/use-projects"; -import { useProjectIconDataByProjectViewKey } from "@/projects/project-icons"; +import type { ProjectEditFormSnapshot } from "@/projects/edit-form"; +import { useProjectIcons } from "@/projects/icons"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; +import { useHostFeature } from "@/runtime/host-features"; import { useToast } from "@/contexts/toast-context"; import { confirmDialog } from "@/utils/confirm-dialog"; import { @@ -45,11 +47,12 @@ import { type ProjectScriptDraft, } from "@/utils/project-config-form"; import { buildProjectsSettingsRoute } from "@/utils/host-routes"; -import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; -import { getProjectHostEntry, getProjectSummaryForHostProject } from "@/utils/projects"; -import { useSessionStore } from "@/stores/session-store"; -import { selectProjectIdForServer } from "@/stores/session-store-hooks/selectors"; -import { resolveProjectSettingsHostTarget } from "@/screens/project-settings-host-selection"; +import { + getProjectHostEntry, + getProjectSummaryForHostProject, + type ProjectHostEntry, + type ProjectSummary, +} from "@/utils/projects"; const SCRIPT_SERVICE_TYPE = "service"; @@ -98,82 +101,47 @@ export default function ProjectSettingsScreen({ serverId, projectId }: ProjectSe () => getProjectSummaryForHostProject(projects, serverId, projectId), [projectId, projects, serverId], ); - const editableHosts = useMemo(() => filterEditableHosts(project), [project]); - const routeKey = `${serverId}:${projectId}`; - const routeHost = getProjectHostEntry(project, serverId, projectId); - const defaultHost = routeHost?.isOnline ? routeHost : editableHosts[0]; - const [hostSelection, setHostSelection] = useState({ - routeKey: "", - serverId: "", - projectId: "", - }); - const selectedTarget = resolveProjectSettingsHostTarget({ - routeKey, - editableHosts, - defaultHost, - selection: hostSelection, - }); - const selectedServerId = selectedTarget?.serverId ?? ""; - const selectedProjectId = selectedTarget?.projectId ?? ""; - const setSelectedServerId = useCallback( - (targetServerId: string) => { - const targetProjectId = selectProjectIdForServer(useSessionStore.getState(), { - sourceServerId: serverId, - projectId, - targetServerId, - }); - if (targetProjectId) { - setHostSelection({ routeKey, serverId: targetServerId, projectId: targetProjectId }); - } - }, - [projectId, routeKey, serverId], - ); - - const selectedSnapshot = useHostRuntimeSnapshot(selectedServerId); + const selectedHost = getProjectHostEntry(project, serverId, projectId); + const selectedSnapshot = useHostRuntimeSnapshot(serverId); const isHostGone = - Boolean(selectedServerId) && + Boolean(serverId) && (selectedSnapshot?.connectionStatus === "offline" || selectedSnapshot?.connectionStatus === "error"); - const selectedHost = getProjectHostEntry(project, selectedServerId, selectedProjectId); - const client = useHostRuntimeClient(selectedHost?.serverId ?? ""); + const client = useHostRuntimeClient(serverId); + const canEdit = + selectedHost?.isOnline === true && + selectedHost.serverId.trim().length > 0 && + selectedHost.repoRoot.trim().length > 0; - if (!project || editableHosts.length === 0 || !selectedHost || !client) { - return ; + if (!project || !selectedHost || !client || !canEdit) { + return ; } return ( ); } -function filterEditableHosts(project: ProjectSummary | undefined): ProjectHostEntry[] { - if (!project) return []; - return project.hosts.filter( - (host) => host.isOnline && host.serverId.trim().length > 0 && host.repoRoot.trim().length > 0, - ); +function navigateBackToProjects(serverId: string) { + router.navigate(buildProjectsSettingsRoute(serverId)); } -function navigateBackToProjects() { - router.navigate(buildProjectsSettingsRoute()); -} - -function NoEditableTarget() { +function NoEditableTarget({ serverId }: { serverId: string }) { const { t } = useTranslation(); + const handleBack = useCallback(() => navigateBackToProjects(serverId), [serverId]); return ( - + {t("settings.project.noEditableTarget")} - - ); + if (props.isPending) { + return {t("pairing.device.loadingOffer")}; } + if (props.error) { + return ; + } + if (!props.offer?.relayEnabled) { + return ; + } + if (!props.offer.url) { + return {t("pairing.device.unavailable")}; + } + return ; +} +function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) { + const { t } = useTranslation(); return ( - - {labels.hint} - - + + + ); +} + +function RelayConsent(props: PairDeviceBodyProps) { + const { t } = useTranslation(); + let enableButtonLabel = t("pairing.device.enableRelay"); + if (props.enablePending) { + enableButtonLabel = t("pairing.device.enablingRelay"); + } else if (props.enableError) { + enableButtonLabel = t("pairing.device.retry"); + } + return ( + + + + {t("pairing.device.enableTitle")} + {t("pairing.device.enableDescription")} + + {props.enableError ? : null} + {!props.canConfigureRelay ? ( + + ) : null} + + + {props.canConfigureRelay ? ( + + ) : null} + + + + {t("pairing.device.directConnectionHint")} + + + ); +} + +function RelayHeroBadge() { + return ( + + + + ); +} + +function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) { + const { t } = useTranslation(); + return ( + + {t("pairing.device.hint")} + + + - ); } -function PairDeviceQrContent(props: { - qrImageSource: { uri: string } | null; - qrQuery: { isError: boolean }; - unavailableLabel: string; -}) { - if (props.qrImageSource) { - return ; +function PairingQr({ svg, isError }: { svg: string | null; isError: boolean }) { + const { t } = useTranslation(); + if (svg) { + return ( + + ); } - if (props.qrQuery.isError) { - return {props.unavailableLabel}; + if (isError) { + return {t("pairing.device.qrUnavailable")}; } return ; } const styles = StyleSheet.create((theme) => ({ - centered: { - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[3], - paddingVertical: theme.spacing[6], - paddingHorizontal: theme.spacing[4], - }, - content: { - gap: theme.spacing[3], - padding: theme.spacing[4], - }, - hint: { + stateLine: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, textAlign: "center", + paddingVertical: theme.spacing[6], }, - qrContainer: { - alignItems: "center", - justifyContent: "center", - alignSelf: "center", - width: 320, - height: 320, - borderRadius: theme.borderRadius.lg, + consent: { + gap: theme.spacing[4], + }, + hero: { + alignItems: "flex-start", + gap: theme.spacing[2], + }, + heroBadge: { + width: 48, + height: 48, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, borderWidth: 1, borderColor: theme.colors.border, - backgroundColor: theme.colors.surface0, - padding: theme.spacing[2], + alignItems: "center", + justifyContent: "center", + marginBottom: theme.spacing[1], + }, + consentTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, + consentDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: theme.fontSize.sm * 1.5, + }, + actions: { + flexDirection: "row", + gap: theme.spacing[3], + }, + directRow: { + flexDirection: "row", + alignItems: "flex-start", + gap: theme.spacing[2], + borderTopWidth: 1, + borderTopColor: theme.colors.border, + paddingTop: theme.spacing[4], + }, + directIcon: { + color: theme.colors.foregroundMuted, + marginTop: 1, // optical: seats the glyph on the hint's first text line + }, + directHint: { + flex: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + lineHeight: theme.fontSize.xs * 1.5, + }, + offer: { + gap: theme.spacing[4], + }, + offerHint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, + qrTile: { + alignSelf: "center", + alignItems: "center", + justifyContent: "center", + width: 304, + maxWidth: "100%", + aspectRatio: 1, + padding: theme.spacing[3], + borderRadius: theme.borderRadius.xl, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.palette.white, }, qrImage: { width: "100%", @@ -290,17 +366,22 @@ const styles = StyleSheet.create((theme) => ({ }, inputWrapper: { flex: 1, - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surface2, overflow: "hidden", }, linkInput: { color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.xs, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], outlineStyle: "none", } as object, + hint: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, })); diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index a4292128d1..af202a73a8 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -40,12 +40,6 @@ export interface DesktopAppLogs { contents: string; } -export interface DesktopPairingOffer { - relayEnabled: boolean; - url: string | null; - qr: string | null; -} - export interface LocalTransportTarget { [key: string]: unknown; transportType: "socket" | "pipe"; @@ -119,17 +113,6 @@ function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs { }; } -function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer { - if (!isRecord(raw)) { - throw new Error("Unexpected desktop daemon pairing response."); - } - return { - relayEnabled: raw.relayEnabled === true, - url: toStringOrNull(raw.url), - qr: toStringOrNull(raw.qr), - }; -} - export function shouldUseDesktopDaemon(): boolean { return isElectronRuntime(); } @@ -167,10 +150,6 @@ export async function getDesktopAppLogs(): Promise { }; } -export async function getDesktopDaemonPairing(): Promise { - return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing")); -} - export async function getCliDaemonStatus(): Promise { const raw = await invokeDesktopCommand("cli_daemon_status"); if (typeof raw !== "string") { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 28a8dcbeba..ee0ba9bb4d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1422,9 +1422,21 @@ export const ar: TranslationResources = { loadingOffer: "جارٍ تحميل عرض الإقران...", failedToLoadOffer: "فشل تحميل عرض الاقتران.", relayDisabled: "لم يتم تمكين التتابع. تمكين التتابع لإقران جهاز.", + enableTitle: "تمكين التتابع؟", + enableDescription: + "يتيح التتابع لهذا الجهاز الاتصال من أي مكان. حركة مرور الإقران مشفرة من طرف إلى طرف.", + relayDocs: "كيفية عمل التتابع", + relayDocsAccessibility: "اقرأ كيفية عمل تتابع Paseo", + enableRelay: "تمكين التتابع", + enablingRelay: "جارٍ التمكين...", + notNow: "ليس الآن", + directConnectionHint: + "بدون التتابع، اتصل مباشرة عبر TCP أو Tailscale أو شبكة VPN أخرى. لن يتم إنشاء رمز QR.", + updateRequired: "حدّث المضيف لتمكين التتابع من Paseo Desktop.", unavailable: "عرض الاقتران غير متاح.", hint: "قم بمسح رمز QR هذا باستخدام Paseo على هاتفك، أو انسخ الرابط أدناه.", qrUnavailable: "رمز QR غير متاح.", + qrAccessibility: "رمز QR للإقران", retry: "أعد المحاولة", copy: "ينسخ", copied: "منقول", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 42d620d85c..f85e581799 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1433,9 +1433,21 @@ export const en = { loadingOffer: "Loading pairing offer...", failedToLoadOffer: "Failed to load pairing offer.", relayDisabled: "Relay is not enabled. Enable relay to pair a device.", + enableTitle: "Enable relay?", + enableDescription: + "Relay lets this device connect from anywhere. Pairing traffic is end-to-end encrypted.", + relayDocs: "How relay works", + relayDocsAccessibility: "Read how Paseo relay works", + enableRelay: "Enable relay", + enablingRelay: "Enabling...", + notNow: "Not now", + directConnectionHint: + "Without relay, connect directly over TCP, Tailscale, or another VPN. No QR code is created.", + updateRequired: "Update the host to enable relay from Paseo Desktop.", unavailable: "Pairing offer unavailable.", hint: "Scan this QR code with Paseo on your phone, or copy the link below.", qrUnavailable: "QR code unavailable.", + qrAccessibility: "Pairing QR code", retry: "Retry", copy: "Copy", copied: "Copied", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index b61d7be9ae..ace986d777 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1465,9 +1465,21 @@ export const es: TranslationResources = { loadingOffer: "Cargando oferta de maridaje...", failedToLoadOffer: "No se pudo cargar la oferta de emparejamiento.", relayDisabled: "El relé no está habilitado. Habilite el relé para emparejar un dispositivo.", + enableTitle: "¿Habilitar el relé?", + enableDescription: + "El relé permite conectar este dispositivo desde cualquier lugar. El tráfico de emparejamiento está cifrado de extremo a extremo.", + relayDocs: "Cómo funciona el relé", + relayDocsAccessibility: "Leer cómo funciona el relé de Paseo", + enableRelay: "Habilitar relé", + enablingRelay: "Habilitando...", + notNow: "Ahora no", + directConnectionHint: + "Sin relé, conéctese directamente por TCP, Tailscale u otra VPN. No se crea ningún código QR.", + updateRequired: "Actualice el host para habilitar el relé desde Paseo Desktop.", unavailable: "Oferta de maridaje no disponible.", hint: "Escanee este códigoQRconPaseoen su teléfono o copie el enlace a continuación.", qrUnavailable: "CódigoQRno disponible.", + qrAccessibility: "Código QR de emparejamiento", retry: "Rever", copy: "Copiar", copied: "Copiado", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index c032f4d962..2a1f6d60bb 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1469,9 +1469,21 @@ export const fr: TranslationResources = { loadingOffer: "Chargement de l'offre d'association...", failedToLoadOffer: "Échec du chargement de l'offre d'association.", relayDisabled: "Le relais n'est pas activé. Activer le relais pour coupler un appareil.", + enableTitle: "Activer le relais ?", + enableDescription: + "Le relais permet à cet appareil de se connecter depuis n’importe où. Le trafic de couplage est chiffré de bout en bout.", + relayDocs: "Fonctionnement du relais", + relayDocsAccessibility: "Lire comment fonctionne le relais Paseo", + enableRelay: "Activer le relais", + enablingRelay: "Activation...", + notNow: "Pas maintenant", + directConnectionHint: + "Sans relais, connectez-vous directement via TCP, Tailscale ou un autre VPN. Aucun code QR n’est créé.", + updateRequired: "Mettez à jour l’hôte pour activer le relais depuis Paseo Desktop.", unavailable: "Offre de jumelage indisponible.", hint: "Scannez ce codeQRavecPaseosur votre téléphone ou copiez le lien ci-dessous.", qrUnavailable: "CodeQRindisponible.", + qrAccessibility: "Code QR de couplage", retry: "Réessayer", copy: "Copie", copied: "Copié", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 5f5d3678f1..47c879ce8f 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1438,9 +1438,21 @@ export const ja: TranslationResources = { failedToLoadOffer: "ペアリングオファーの読み込みに失敗しました。", relayDisabled: "リレーが有効になっていません。デバイスをペアリングするにはリレーを有効にしてください。", + enableTitle: "リレーを有効にしますか?", + enableDescription: + "リレーを使うと、このデバイスからどこでも接続できます。ペアリング通信はエンドツーエンドで暗号化されます。", + relayDocs: "リレーの仕組み", + relayDocsAccessibility: "Paseo リレーの仕組みを読む", + enableRelay: "リレーを有効にする", + enablingRelay: "有効化中...", + notNow: "今はしない", + directConnectionHint: + "リレーを使わない場合は、TCP、Tailscale、または別の VPN で直接接続してください。QR コードは作成されません。", + updateRequired: "Paseo Desktop からリレーを有効にするにはホストを更新してください。", unavailable: "ペアリングオファーが利用できません。", hint: "スマートフォンのPaseoでこのQRコードをスキャンするか、以下のリンクをコピーしてください。", qrUnavailable: "QRコードが利用できません。", + qrAccessibility: "ペアリング QR コード", retry: "再試行", copy: "コピー", copied: "コピーしました", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index cbedae0347..1fb445f83f 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1451,9 +1451,21 @@ export const ptBR: TranslationResources = { loadingOffer: "Carregando oferta de pareamento...", failedToLoadOffer: "Falha ao carregar oferta de pareamento.", relayDisabled: "Relay não está ativado. Ative o relay para parear um dispositivo.", + enableTitle: "Ativar o relay?", + enableDescription: + "O relay permite conectar este dispositivo de qualquer lugar. O tráfego de pareamento é criptografado de ponta a ponta.", + relayDocs: "Como o relay funciona", + relayDocsAccessibility: "Leia como o relay do Paseo funciona", + enableRelay: "Ativar relay", + enablingRelay: "Ativando...", + notNow: "Agora não", + directConnectionHint: + "Sem relay, conecte diretamente por TCP, Tailscale ou outra VPN. Nenhum código QR é criado.", + updateRequired: "Atualize o host para ativar o relay pelo Paseo Desktop.", unavailable: "Oferta de pareamento indisponível.", hint: "Escaneie este QR code com o Paseo no seu celular ou copie o link abaixo.", qrUnavailable: "QR code indisponível.", + qrAccessibility: "Código QR de pareamento", retry: "Tentar novamente", copy: "Copiar", copied: "Copiado", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 6e28e3a6ac..de3135426d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1456,9 +1456,21 @@ export const ru: TranslationResources = { loadingOffer: "Загрузка предложения по сопряжению...", failedToLoadOffer: "Не удалось загрузить предложение сопряжения.", relayDisabled: "Реле не включено. Включите реле для сопряжения устройства.", + enableTitle: "Включить реле?", + enableDescription: + "Реле позволяет подключаться с этого устройства откуда угодно. Трафик сопряжения защищён сквозным шифрованием.", + relayDocs: "Как работает реле", + relayDocsAccessibility: "Прочитать, как работает реле Paseo", + enableRelay: "Включить реле", + enablingRelay: "Включение...", + notNow: "Не сейчас", + directConnectionHint: + "Без реле подключайтесь напрямую через TCP, Tailscale или другую VPN. QR-код не создаётся.", + updateRequired: "Обновите хост, чтобы включить реле из Paseo Desktop.", unavailable: "Предложение по сопряжению недоступно.", hint: "Отсканируйте этот код QR с помощью Paseo на своем телефоне или скопируйте ссылку ниже.", qrUnavailable: "Код QR недоступен.", + qrAccessibility: "QR-код сопряжения", retry: "Повторить попытку", copy: "Копировать", copied: "Скопировано", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index be91814a6b..3aaece91d6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1403,9 +1403,20 @@ export const zhCN: TranslationResources = { loadingOffer: "正在加载配对 offer...", failedToLoadOffer: "加载配对 offer 失败。", relayDisabled: "Relay 未启用。启用 relay 后才能配对设备。", + enableTitle: "启用中继?", + enableDescription: "中继让此设备可以从任何地方连接。配对流量采用端到端加密。", + relayDocs: "中继如何工作", + relayDocsAccessibility: "阅读 Paseo 中继的工作原理", + enableRelay: "启用中继", + enablingRelay: "正在启用...", + notNow: "暂不", + directConnectionHint: + "不使用中继时,请通过 TCP、Tailscale 或其他 VPN 直接连接。不会生成二维码。", + updateRequired: "请更新主机,以便从 Paseo Desktop 启用中继。", unavailable: "配对 offer 不可用。", hint: "用手机上的 Paseo 扫描此二维码,或复制下方链接。", qrUnavailable: "二维码不可用。", + qrAccessibility: "配对二维码", retry: "重试", copy: "复制", copied: "已复制", diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 20ae9e04d8..c5cffe9330 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -132,6 +132,7 @@ export function OpenProjectScreen() { ; case "connections": return ; + case "pair-device": + return ; case "agents": return ; case "workspaces": @@ -1262,8 +1267,7 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti if (view.kind !== "host") { return; } - const section: HostSectionSlug = view.section; - const target = buildSettingsHostSectionRoute(serverId, section); + const target = buildSettingsHostSectionRoute(serverId, view.section); if (isCompactLayout) { router.push(target); } else { diff --git a/packages/app/src/screens/settings/browser-tools-config.test.ts b/packages/app/src/screens/settings/browser-tools-config.test.ts index 99b9fa3a1c..e9358792ea 100644 --- a/packages/app/src/screens/settings/browser-tools-config.test.ts +++ b/packages/app/src/screens/settings/browser-tools-config.test.ts @@ -9,6 +9,7 @@ import { function makeConfig(browserToolsEnabled = false): MutableDaemonConfig { return { + relay: { enabled: false }, mcp: { injectIntoAgents: false }, browserTools: { enabled: browserToolsEnabled }, providers: {}, diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 4fde683677..2ebeefd2bf 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -237,9 +237,7 @@ function HostConnectionError({ serverId }: { serverId: string }) { } export function HostConnectionsPage({ serverId }: { serverId: string }) { - const { t } = useTranslation(); const host = useHostProfile(serverId); - const isLocalDaemon = useIsLocalDaemon(serverId); if (!host) { return ; @@ -249,15 +247,25 @@ export function HostConnectionsPage({ serverId }: { serverId: string }) { - {isLocalDaemon ? ( - - - - ) : null} ); } +export function HostPairDevicePage({ serverId }: { serverId: string }) { + const { t } = useTranslation(); + const host = useHostProfile(serverId); + + if (!host) { + return ; + } + + return ( + + + + ); +} + export function HostAgentsPage({ serverId }: { serverId: string }) { const { t } = useTranslation(); const host = useHostProfile(serverId); @@ -1241,7 +1249,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) { ); } -function PairDeviceRow() { +function PairDeviceRow({ serverId }: { serverId: string }) { const { t } = useTranslation(); const { theme } = useUnistyles(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -1265,6 +1273,7 @@ function PairDeviceRow() { { describe("host settings section slugs", () => { it("keeps current host settings sections", () => { expect(normalizeHostSectionSlug("connections")).toBe("connections"); + expect(normalizeHostSectionSlug("pair-device")).toBe("pair-device"); expect(normalizeHostSectionSlug("agents")).toBe("agents"); expect(normalizeHostSectionSlug("workspaces")).toBe("workspaces"); expect(normalizeHostSectionSlug("projects")).toBe("projects"); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 57b3e3401c..3d8391eef2 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -509,6 +509,7 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl export const HOST_SECTION_SLUGS = [ "projects", "connections", + "pair-device", "agents", "workspaces", "providers", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 72deee4a61..0455b81b2b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -139,6 +139,7 @@ export function createCli(): Command { "Listen target for restarted daemon (host:port, port, or unix socket)", ) .option("--port ", "Port for restarted daemon listen target") + .option("--relay", "Enable relay on restarted daemon") .option("--no-relay", "Disable relay on restarted daemon") .option("--no-mcp", "Disable Agent MCP on restarted daemon") .option( diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index 82547cdbdc..73bb294d7b 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -40,6 +40,7 @@ export function createDaemonCommand(): Command { "Listen target for restarted daemon (host:port, port, or unix socket)", ) .option("--port ", "Port for restarted daemon listen target") + .option("--relay", "Enable relay on restarted daemon") .option("--no-relay", "Disable relay on restarted daemon") .option("--no-mcp", "Disable Agent MCP on restarted daemon") .option("--no-inject-mcp", "Disable auto-injecting the Paseo MCP into created agents") diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index 06f124e13d..a7afb4e6b7 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -128,6 +128,9 @@ function envWithHome(home?: string): NodeJS.ProcessEnv { function buildRunnerArgs(options: DaemonStartOptions): string[] { const args: string[] = []; + if (options.relay === true) { + args.push("--relay"); + } if (options.relay === false) { args.push("--no-relay"); } diff --git a/packages/cli/src/commands/daemon/pair.test.ts b/packages/cli/src/commands/daemon/pair.test.ts new file mode 100644 index 0000000000..79117ee16a --- /dev/null +++ b/packages/cli/src/commands/daemon/pair.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test, vi } from "vitest"; + +import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js"; + +const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null }; +const enabledOffer: PairingOffer = { + relayEnabled: true, + url: "https://app.paseo.sh/#offer=test", + qr: null, +}; + +interface RecordedPairCommandOutput extends PairCommandOutput { + stdout: string[]; + stderr: string[]; + successes: string[]; + exitCode: number | undefined; +} + +function createRecordedOutput(): RecordedPairCommandOutput { + return { + columns: 80, + stdout: [], + stderr: [], + successes: [], + exitCode: undefined, + writeStdout(message) { + this.stdout.push(message); + }, + writeStderr(message) { + this.stderr.push(message); + }, + setExitCode(code) { + this.exitCode = code; + }, + success(message) { + this.successes.push(message); + }, + }; +} + +describe("daemon pair workflow", () => { + test("interactive decline prints direct guidance and creates no pairing output", async () => { + const resolveOffer = vi.fn(async () => disabledOffer); + const confirmRelay = vi.fn(async () => false); + const printDirectGuidance = vi.fn(); + const output = createRecordedOutput(); + + await runPairCommand( + {}, + { resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output }, + ); + + expect(confirmRelay).toHaveBeenCalledOnce(); + expect(printDirectGuidance).toHaveBeenCalledOnce(); + expect(output.stderr.join("")).toContain("No pairing QR was created"); + expect(output.exitCode).toBe(1); + }); + + test("interactive consent enables relay and prints the refreshed offer", async () => { + const resolveOffer = vi + .fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise>() + .mockResolvedValueOnce(disabledOffer) + .mockResolvedValueOnce(enabledOffer); + const output = createRecordedOutput(); + + await runPairCommand( + {}, + { + resolveOffer, + confirmRelay: async () => true, + printDirectGuidance: vi.fn(), + isInteractive: () => true, + output, + }, + ); + + expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true })); + expect(output.stdout.join("")).toContain(enabledOffer.url ?? ""); + expect(output.successes).toEqual(["Relay enabled"]); + }); + + test("JSON mode never prompts and returns a structured relay-disabled error", async () => { + const confirmRelay = vi.fn(async () => true); + const output = createRecordedOutput(); + + await runPairCommand( + { json: true }, + { + resolveOffer: async () => disabledOffer, + confirmRelay, + printDirectGuidance: vi.fn(), + isInteractive: () => true, + output, + }, + ); + + expect(confirmRelay).not.toHaveBeenCalled(); + expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"'); + expect(output.exitCode).toBe(1); + }); + + test("explicit relay opts in without prompting", async () => { + const resolveOffer = vi.fn(async () => enabledOffer); + const confirmRelay = vi.fn(async () => false); + const output = createRecordedOutput(); + + await runPairCommand( + { relay: true, json: true }, + { + resolveOffer, + confirmRelay, + printDirectGuidance: vi.fn(), + isInteractive: () => false, + output, + }, + ); + + expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true })); + expect(confirmRelay).not.toHaveBeenCalled(); + expect(output.exitCode).toBeUndefined(); + }); + + test("surfaces launch-override rejection", async () => { + await expect( + runPairCommand( + { relay: true }, + { + resolveOffer: async () => { + throw new Error("Relay is controlled by a daemon launch override"); + }, + confirmRelay: vi.fn(), + printDirectGuidance: vi.fn(), + isInteractive: () => false, + output: createRecordedOutput(), + }, + ), + ).rejects.toThrow("launch override"); + }); +}); diff --git a/packages/cli/src/commands/daemon/pair.ts b/packages/cli/src/commands/daemon/pair.ts index a2ca3aab2a..366366b76c 100644 --- a/packages/cli/src/commands/daemon/pair.ts +++ b/packages/cli/src/commands/daemon/pair.ts @@ -1,66 +1,97 @@ +import { confirm, isCancel, log } from "@clack/prompts"; import { Command } from "commander"; import chalk from "chalk"; -import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server"; +import { + generateLocalPairingOffer, + getOrCreateServerId, + loadConfig, + resolvePaseoHome, +} from "@getpaseo/server"; import { tryConnectToDaemon } from "../../utils/client.js"; -import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; +import { resolveLocalDaemonState } from "./local-daemon.js"; import { addJsonOption } from "../../utils/command-options.js"; import { formatPairingInstructions } from "../../output/pairing.js"; interface PairOptions { home?: string; json?: boolean; + relay?: boolean; +} + +export interface PairCommandDependencies { + resolveOffer: typeof resolveLocalPairingOffer; + confirmRelay: typeof confirmRelayPairing; + printDirectGuidance: typeof printDirectConnectionGuidance; + isInteractive: () => boolean; + output: PairCommandOutput; +} + +export interface PairCommandOutput { + columns: number | undefined; + writeStdout(message: string): void; + writeStderr(message: string): void; + setExitCode(code: number): void; + success(message: string): void; +} + +export interface PairingOffer { + relayEnabled: boolean; + url: string | null; + qr: string | null; } const PAIRING_DAEMON_RPC_TIMEOUT_MS = 1500; +const RELAY_DOCS_URL = "https://paseo.sh/docs/security"; + +function createProcessOutput(): PairCommandOutput { + return { + columns: process.stdout.columns, + writeStdout(message) { + process.stdout.write(message); + }, + writeStderr(message) { + process.stderr.write(message); + }, + setExitCode(code) { + process.exitCode = code; + }, + success(message) { + log.success(message); + }, + }; +} export function pairCommand(): Command { return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link")) .option("--home ", "Paseo home directory (default: ~/.paseo)") + .option("--relay", "Enable relay without prompting") .action(async (_options: PairOptions, command: Command) => { await runPairCommand(command.optsWithGlobals()); }); } -export async function runPairCommand(options: PairOptions): Promise { - if (options.home) { - process.env.PASEO_HOME = options.home; +export async function resolveLocalPairingOffer(options: { + paseoHome: string; + enableRelay?: boolean; +}): Promise { + const state = resolveLocalDaemonState({ home: options.paseoHome }); + const serverId = getOrCreateServerId(state.home); + const daemonOffer = await resolveDaemonPairingOffer(state.listen, serverId, options.enableRelay); + if (daemonOffer) return daemonOffer; + + if (state.running) { + throw new Error( + "The running daemon did not provide a pairing offer. Check daemon connectivity or update the daemon.", + ); } - const paseoHome = resolvePaseoHome(); - const state = resolveLocalDaemonState({ home: paseoHome }); - const host = resolveTcpHostFromListen(state.listen); - - // Try to get the pairing offer from the running daemon first. - if (host) { - const client = await tryConnectToDaemon({ host, timeout: 1500 }); - if (client) { - const supportsDaemonStatusRpc = - client.getLastServerInfoMessage()?.features?.daemonStatusRpc === true; - if (supportsDaemonStatusRpc) { - try { - const offer = await client.getDaemonPairingOffer({ - timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, - }); - await client.close().catch(() => {}); - outputPairingResult( - { relayEnabled: offer.relayEnabled, url: offer.url, qr: offer.qr ?? null }, - options, - ); - return; - } catch { - // COMPAT(daemon-rpc-rollout): fall back to CLI-side pairing generation while - // old daemons lack daemonStatusRpc. Remove once the daemon floor is past - // v0.1.76; pairing should come from daemon.get_pairing_offer. - } - } - await client.close().catch(() => {}); - } + const config = loadConfig(options.paseoHome); + if (options.enableRelay && !config.relayEnabled) { + throw new Error("Start the daemon before enabling relay for pairing."); } - // Fall back to local pairing offer generation. - const config = loadConfig(paseoHome); - const pairing = await generateLocalPairingOffer({ - paseoHome, + return generateLocalPairingOffer({ + paseoHome: options.paseoHome, relayEnabled: config.relayEnabled, relayEndpoint: config.relayEndpoint, relayPublicEndpoint: config.relayPublicEndpoint, @@ -69,28 +100,132 @@ export async function runPairCommand(options: PairOptions): Promise { appBaseUrl: config.appBaseUrl, includeQr: true, }); +} - outputPairingResult(pairing, options); +async function resolveDaemonPairingOffer( + listen: string, + expectedServerId: string, + enableRelay: boolean | undefined, +): Promise { + const client = await tryConnectToDaemon({ + host: listen, + timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, + }); + if (!client) return null; + + try { + const serverInfo = client.getLastServerInfoMessage(); + if (serverInfo?.serverId.trim() !== expectedServerId) { + throw new Error( + "The reachable daemon belongs to a different Paseo home. Check --home or the daemon listen configuration.", + ); + } + if (serverInfo?.features?.daemonStatusRpc !== true) { + throw new Error("Update the Paseo daemon before pairing from this command."); + } + + let offer = await client.getDaemonPairingOffer({ + timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, + }); + if (!offer.relayEnabled && enableRelay) { + if (serverInfo.features.relayConfig !== true) { + throw new Error("Update the Paseo daemon before enabling relay from this command."); + } + await client.patchDaemonConfig({ relay: { enabled: true } }); + offer = await client.getDaemonPairingOffer({ + timeout: PAIRING_DAEMON_RPC_TIMEOUT_MS, + }); + } + return { + relayEnabled: offer.relayEnabled, + url: offer.url || null, + qr: offer.qr ?? null, + }; + } finally { + await client.close().catch(() => undefined); + } +} + +export async function confirmRelayPairing(): Promise { + log.message("Your connection is end-to-end encrypted. Paseo cannot read your code or messages."); + log.message(`Learn how it works: ${RELAY_DOCS_URL}`); + const answer = await confirm({ + message: "Enable relay to pair a device?", + initialValue: false, + }); + return !isCancel(answer) && answer; +} + +export function printDirectConnectionGuidance(): void { + console.log("Daemon is running with relay off."); + console.log( + "To connect another device directly, use the daemon's TCP address over your LAN, Tailscale, or another VPN.", + ); + console.log(`Learn more: ${RELAY_DOCS_URL}#direct-connections`); +} + +export async function runPairCommand( + options: PairOptions, + dependencyOverrides: Partial = {}, +): Promise { + if (options.home) process.env.PASEO_HOME = options.home; + const dependencies: PairCommandDependencies = { + resolveOffer: resolveLocalPairingOffer, + confirmRelay: confirmRelayPairing, + printDirectGuidance: printDirectConnectionGuidance, + isInteractive: () => Boolean(process.stdin.isTTY && process.stdout.isTTY), + output: createProcessOutput(), + ...dependencyOverrides, + }; + + const paseoHome = resolvePaseoHome(); + let pairing = await dependencies.resolveOffer({ + paseoHome, + enableRelay: options.relay === true, + }); + + const canPrompt = dependencies.isInteractive() && options.json !== true; + if (!pairing.relayEnabled && canPrompt) { + const shouldEnable = await dependencies.confirmRelay(); + if (!shouldEnable) { + dependencies.printDirectGuidance(); + dependencies.output.writeStderr(`${chalk.yellow("No pairing QR was created.")}\n`); + dependencies.output.setExitCode(1); + return; + } + pairing = await dependencies.resolveOffer({ paseoHome, enableRelay: true }); + dependencies.output.success("Relay enabled"); + } + + outputPairingResult(pairing, options, dependencies.output); } function outputPairingResult( - pairing: { relayEnabled: boolean; url: string | null; qr: string | null }, + pairing: PairingOffer, options: PairOptions, + output: PairCommandOutput, ): void { if (!pairing.relayEnabled || !pairing.url) { - console.error(chalk.red("Relay pairing is disabled for this daemon config.")); - console.error(chalk.yellow("Enable relay and run this command again.")); - process.exit(1); + if (options.json) { + output.writeStderr( + `${JSON.stringify({ + code: "RELAY_DISABLED", + message: "Relay pairing is disabled for this daemon.", + action: "Run paseo daemon pair --relay --json to enable it explicitly.", + })}\n`, + ); + } else { + output.writeStderr(`${chalk.red("Relay pairing is disabled for this daemon.")}\n`); + output.writeStderr(`${chalk.yellow("Run paseo daemon pair --relay to enable it.")}\n`); + } + output.setExitCode(1); + return; } if (options.json) { - process.stdout.write( + output.writeStdout( `${JSON.stringify( - { - relayEnabled: pairing.relayEnabled, - url: pairing.url, - qr: pairing.qr, - }, + { relayEnabled: pairing.relayEnabled, url: pairing.url, qr: pairing.qr }, null, 2, )}\n`, @@ -98,11 +233,11 @@ function outputPairingResult( return; } - process.stdout.write( + output.writeStdout( formatPairingInstructions({ url: pairing.url, qr: pairing.qr, - columns: process.stdout.columns, + columns: output.columns, }), ); } diff --git a/packages/cli/src/commands/daemon/start.ts b/packages/cli/src/commands/daemon/start.ts index abb38fc1e2..3a4c3df44d 100644 --- a/packages/cli/src/commands/daemon/start.ts +++ b/packages/cli/src/commands/daemon/start.ts @@ -20,6 +20,7 @@ export function startCommand(): Command { .option("--port ", "Port to listen on (default: 6767)") .option("--home ", "Paseo home directory (default: ~/.paseo)") .option("--foreground", "Run in foreground (don't daemonize)") + .option("--relay", "Enable relay connection") .option("--no-relay", "Disable relay connection") .option("--relay-use-tls", "Use wss:// for the relay connection and pairing offers") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") diff --git a/packages/cli/src/commands/daemon/status.test.ts b/packages/cli/src/commands/daemon/status.test.ts new file mode 100644 index 0000000000..dd900f902f --- /dev/null +++ b/packages/cli/src/commands/daemon/status.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "vitest"; +import { selectRelayStatus } from "./status.js"; + +describe("selectRelayStatus", () => { + const persisted = { + enabled: false, + endpoint: "persisted.internal:443", + publicEndpoint: "persisted.example.com:443", + useTls: true, + publicUseTls: true, + }; + + test("uses the running daemon relay state over persisted config", () => { + expect( + selectRelayStatus({ + persisted, + live: { + enabled: true, + endpoint: "live.internal:443", + publicEndpoint: "live.example.com:443", + useTls: true, + publicUseTls: true, + }, + }), + ).toBe("wss://live.example.com:443"); + }); + + test("falls back to persisted config when the daemon cannot report live state", () => { + expect(selectRelayStatus({ persisted })).toBe("disabled"); + }); +}); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 392279f4ad..87e34f0bcf 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -3,7 +3,7 @@ import { createRequire } from "node:module"; import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; -import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; +import { resolveLocalDaemonState } from "./local-daemon.js"; import { resolveNodePathFromPid } from "./runtime-toolchain.js"; const DAEMON_STATUS_PROBE_TIMEOUT_MS = 1500; @@ -204,6 +204,7 @@ interface DaemonProbeResult { daemonVersion?: string | null; daemonNodeOverride?: string; daemonProviders?: ProviderBinaryStatus[]; + relayStatus?: string; note?: string; } @@ -262,6 +263,13 @@ async function probeDaemonOverWebsocket(args: { version: p.available ? null : (p.error ?? null), source: "daemon" as const, })); + const relayStatus = + statusPayload.relay == null + ? undefined + : selectRelayStatus({ + persisted: relayConfigFromLocalState(state), + live: statusPayload.relay, + }); if (!state.running) { return { @@ -269,6 +277,7 @@ async function probeDaemonOverWebsocket(args: { daemonVersion: statusPayload.version ?? daemonVersion, daemonNodeOverride: statusPayload.nodePath, daemonProviders, + relayStatus, note: state.pidInfo ? `Connected daemon is reachable at ${host} even though local daemon PID ${state.pidInfo.pid} is stale` : `Connected daemon is reachable at ${host} but no local daemon PID file was found`, @@ -280,6 +289,7 @@ async function probeDaemonOverWebsocket(args: { daemonVersion: statusPayload.version ?? daemonVersion, daemonNodeOverride: statusPayload.nodePath, daemonProviders, + relayStatus, }; } catch { return { @@ -301,6 +311,7 @@ interface ProbeMergeState { daemonNode: string; daemonVersion: string | null; daemonProviders: ProviderBinaryStatus[] | undefined; + relayStatus: string; note: string | undefined; } @@ -312,6 +323,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit): string { - if (!state.relayEnabled) return "disabled"; - const scheme = state.relayPublicUseTls ? "wss" : "ws"; - return `${scheme}://${state.relayEndpoint}`; +interface RelayStatusConfig { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; +} + +function relayConfigFromLocalState( + state: ReturnType, +): RelayStatusConfig { + return { + enabled: state.relayEnabled, + endpoint: state.relayEndpoint, + publicEndpoint: state.relayEndpoint, + useTls: state.relayUseTls, + publicUseTls: state.relayPublicUseTls, + }; +} + +export function selectRelayStatus(input: { + persisted: RelayStatusConfig; + live?: RelayStatusConfig; +}): string { + const relay = input.live ?? input.persisted; + if (!relay.enabled) return "disabled"; + const scheme = relay.publicUseTls ? "wss" : "ws"; + return `${scheme}://${relay.publicEndpoint}`; } export type StatusResult = ListResult; @@ -350,7 +386,7 @@ export async function runStatusCommand( ): Promise { const home = typeof options.home === "string" ? options.home : undefined; const state = resolveLocalDaemonState({ home }); - const host = resolveTcpHostFromListen(state.listen); + const daemonTarget = state.listen.trim(); const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname); let daemonNode = await resolveDaemonNodeLabel(state); @@ -359,6 +395,7 @@ export async function runStatusCommand( let connectedDaemon: DaemonStatus["connectedDaemon"] = "not_probed"; let daemonVersion: string | null = null; let daemonProviders: ProviderBinaryStatus[] | undefined; + let relayStatus = selectRelayStatus({ persisted: relayConfigFromLocalState(state) }); let note: string | undefined; if (!state.running && state.stalePidFile && state.pidInfo) { @@ -366,20 +403,26 @@ export async function runStatusCommand( note = `Stale PID file found for PID ${state.pidInfo.pid}`; } - if (host) { - const probe = await probeDaemonOverWebsocket({ host, state }); - ({ connectedDaemon, localDaemon, daemonNode, daemonVersion, daemonProviders, note } = - applyProbeToStatus({ - probe, - connectedDaemon, - localDaemon, - daemonNode, - daemonVersion, - daemonProviders, - note, - })); - } else { - note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped"); + if (daemonTarget) { + const probe = await probeDaemonOverWebsocket({ host: daemonTarget, state }); + ({ + connectedDaemon, + localDaemon, + daemonNode, + daemonVersion, + daemonProviders, + relayStatus, + note, + } = applyProbeToStatus({ + probe, + connectedDaemon, + localDaemon, + daemonNode, + daemonVersion, + daemonProviders, + relayStatus, + note, + })); } const cliVersion = resolveCliVersion(); @@ -398,7 +441,7 @@ export async function runStatusCommand( connectedDaemon, home: state.home, listen: state.listen, - relay: formatRelayStatus(state), + relay: relayStatus, hostname: state.pidInfo?.hostname ?? null, pid: state.pidInfo?.pid ?? null, startedAt: state.pidInfo?.startedAt ?? null, diff --git a/packages/cli/src/commands/onboard.ts b/packages/cli/src/commands/onboard.ts index 22e10cf4af..1ce0ac4283 100644 --- a/packages/cli/src/commands/onboard.ts +++ b/packages/cli/src/commands/onboard.ts @@ -2,13 +2,7 @@ import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@cl import { Command, Option } from "commander"; import { writeFileSync } from "node:fs"; import path from "node:path"; -import { - generateLocalPairingOffer, - loadConfig, - loadPersistedConfig, - type CliConfigOverrides, - type PersistedConfig, -} from "@getpaseo/server"; +import { loadPersistedConfig, type PersistedConfig } from "@getpaseo/server"; import { resolveLocalPaseoHome, resolveLocalDaemonState, @@ -19,6 +13,11 @@ import { } from "./daemon/local-daemon.js"; import { tryConnectToDaemon } from "../utils/client.js"; import { formatPairingInstructions } from "../output/pairing.js"; +import { + confirmRelayPairing, + printDirectConnectionGuidance, + resolveLocalPairingOffer, +} from "./daemon/pair.js"; interface OnboardOptions extends DaemonStartOptions { timeout?: string; @@ -70,37 +69,6 @@ function parseTimeoutMs(raw: string | undefined): number { return Math.ceil(seconds * 1000); } -function toCliOverrides(options: OnboardOptions): CliConfigOverrides { - const cliOverrides: CliConfigOverrides = {}; - - if (options.listen) { - cliOverrides.listen = options.listen; - } else if (options.port) { - cliOverrides.listen = `127.0.0.1:${options.port}`; - } - - if (options.relay === false) { - cliOverrides.relayEnabled = false; - } - - if (options.hostnames) { - const raw = options.hostnames.trim(); - cliOverrides.hostnames = - raw.toLowerCase() === "true" - ? true - : raw - .split(",") - .map((host) => host.trim()) - .filter(Boolean); - } - - if (options.mcp === false) { - cliOverrides.mcpEnabled = false; - } - - return cliOverrides; -} - function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void { const configPath = path.join(paseoHome, "config.json"); writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); @@ -338,6 +306,7 @@ export function onboardCommand(): Command { .option("--listen ", "Listen target (host:port, port, or unix socket path)") .option("--port ", "Port to listen on (default: 6767)") .option("--home ", "Paseo home directory (default: ~/.paseo)") + .option("--relay", "Enable relay connection without prompting") .option("--no-relay", "Disable relay connection") .option("--no-mcp", "Disable the Agent MCP HTTP endpoint") .option( @@ -477,8 +446,6 @@ export async function runOnboard(options: OnboardOptions): Promise { } const voiceEnabled = await resolveAndPersistVoice(paseoHome, options); - const config = loadConfig(paseoHome, { cli: toCliOverrides(options) }); - log.message( voiceEnabled ? "Voice features enabled. Local speech models will be downloaded automatically if missing." @@ -492,26 +459,30 @@ export async function runOnboard(options: OnboardOptions): Promise { richUi, }); - if (config.relayEnabled === false) { - log.warn("Relay is disabled; pairing offer is unavailable for this daemon."); + if (options.relay === false) { + log.message("Relay pairing skipped because --no-relay was provided."); printNextSteps(null, paseoHome, richUi); - if (richUi) { - outro("Paseo daemon is running."); - } + if (richUi) outro("Paseo daemon is running."); return; } - const pairing = await generateLocalPairingOffer({ + let pairing = await resolveLocalPairingOffer({ paseoHome, - relayEnabled: config.relayEnabled, - relayEndpoint: config.relayEndpoint, - relayPublicEndpoint: config.relayPublicEndpoint, - relayUseTls: config.relayUseTls, - relayPublicUseTls: config.relayPublicUseTls, - appBaseUrl: config.appBaseUrl, - includeQr: true, + enableRelay: options.relay === true, }); + if (!pairing.relayEnabled) { + const shouldEnable = richUi ? await confirmRelayPairing() : false; + if (!shouldEnable) { + printDirectConnectionGuidance(); + printNextSteps(null, paseoHome, richUi); + if (richUi) outro("Paseo daemon is running."); + return; + } + pairing = await resolveLocalPairingOffer({ paseoHome, enableRelay: true }); + log.success("Relay enabled"); + } + if (!pairing.url) { log.warn("Relay pairing URL is unavailable for this daemon configuration."); printNextSteps(null, paseoHome, richUi); diff --git a/packages/cli/src/run.test.ts b/packages/cli/src/run.test.ts index 163e2f89f0..6424f4c0ff 100644 --- a/packages/cli/src/run.test.ts +++ b/packages/cli/src/run.test.ts @@ -15,6 +15,23 @@ describe("runCli", () => { ).toEqual(["node", "paseo", "onboard"]); }); + it("routes explicit root relay flags to onboard", () => { + expect( + createCliParseArgv({ + argv: ["--relay"], + cwd: process.cwd(), + nodeArgv: ["node", "paseo"], + }), + ).toEqual(["node", "paseo", "onboard", "--relay"]); + expect( + createCliParseArgv({ + argv: ["--no-relay"], + cwd: process.cwd(), + nodeArgv: ["node", "paseo"], + }), + ).toEqual(["node", "paseo", "onboard", "--no-relay"]); + }); + it("preserves known CLI command argv", () => { expect( createCliParseArgv({ diff --git a/packages/cli/src/run.ts b/packages/cli/src/run.ts index a6a85910d5..4b679c5298 100644 --- a/packages/cli/src/run.ts +++ b/packages/cli/src/run.ts @@ -25,7 +25,13 @@ export function createCliParseArgv(input: { } const nodeArgv = input.nodeArgv ?? ["paseo", "paseo"]; - const cliArgv = invocation.argv.length === 0 ? ["onboard"] : invocation.argv; + const isOnboardRootFlag = invocation.argv[0] === "--relay" || invocation.argv[0] === "--no-relay"; + let cliArgv = invocation.argv; + if (invocation.argv.length === 0) { + cliArgv = ["onboard"]; + } else if (isOnboardRootFlag) { + cliArgv = ["onboard", ...invocation.argv]; + } return [...nodeArgv, ...cliArgv]; } diff --git a/packages/cli/src/utils/client.ts b/packages/cli/src/utils/client.ts index 697b1b0300..e2cd972238 100644 --- a/packages/cli/src/utils/client.ts +++ b/packages/cli/src/utils/client.ts @@ -200,7 +200,10 @@ function stripIpcPrefix(trimmed: string): string { } export function resolveDaemonTarget(host: string): DaemonTarget { - const trimmed = host.trim(); + const trimmed = normalizeDaemonHost(host); + if (!trimmed) { + throw new Error(`Invalid daemon target: ${host}`); + } if ( trimmed.startsWith("unix://") || trimmed.startsWith("pipe://") || diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index d7bca6aec3..b5401fb5ba 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -7,15 +7,16 @@ * * Tests: * - daemon --help shows subcommands - * - daemon pair prints a local pairing link without requiring a running daemon + * - daemon pair does not create a relay offer without explicit consent * - daemon status reports stopped when daemon not running * - daemon status --json outputs valid JSON * - daemon stop handles daemon not running gracefully * - daemon restart starts the daemon and can be cleaned up + * - daemon status probes the live relay state over local IPC */ import assert from "node:assert"; -import { mkdtemp, rm } from "fs/promises"; +import { mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; import { runLocalPaseo } from "./helpers/local-cli.ts"; @@ -44,14 +45,17 @@ try { console.log("✓ daemon --help shows subcommands\n"); } - // Test 2: daemon pair works without daemon process + // Test 2: non-interactive pairing keeps relay disabled by default { - console.log("Test 2: daemon pair prints local pairing URL"); + console.log("Test 2: daemon pair requires explicit relay consent"); const result = await daemonCommand(["pair"]); - assert.strictEqual(result.exitCode, 0, "daemon pair should succeed"); - assert(result.stdout.includes("Scan to pair:"), "output should include scan header"); - assert(result.stdout.includes("#offer="), "output should include pairing offer fragment"); - console.log("✓ daemon pair prints local pairing URL\n"); + assert.strictEqual(result.exitCode, 1, "daemon pair should require relay consent"); + assert( + result.stderr.includes("Relay pairing is disabled"), + "output should explain that relay pairing is disabled", + ); + assert(!result.stdout.includes("#offer="), "output should not include a pairing offer"); + console.log("✓ daemon pair requires explicit relay consent\n"); } // Test 3: daemon status reports stopped when daemon not running @@ -65,16 +69,17 @@ try { console.log("✓ daemon status reports stopped when not running\n"); } - // Test 4: daemon pair --json outputs valid JSON + // Test 4: daemon pair --json exposes the machine-readable disabled state { - console.log("Test 4: daemon pair --json outputs JSON"); + console.log("Test 4: daemon pair --json reports relay disabled"); const result = await daemonCommand(["pair", "--json"]); - assert.strictEqual(result.exitCode, 0, "daemon pair --json should succeed"); - const pairing = JSON.parse(result.stdout); - assert.strictEqual(pairing.relayEnabled, true, "pairing should report relay enabled"); - assert.match(pairing.url, /#offer=/, "pairing URL should include offer fragment"); - assert.strictEqual(typeof pairing.qr, "string", "pairing should include QR content"); - console.log("✓ daemon pair --json outputs valid JSON\n"); + assert.strictEqual(result.exitCode, 1, "daemon pair --json should require relay consent"); + const errorLine = result.stderr.split("\n").find((line) => line.startsWith("{")); + assert(errorLine, "stderr should include a structured error"); + const error = JSON.parse(errorLine); + assert.strictEqual(error.code, "RELAY_DISABLED", "error should identify relay state"); + assert(!result.stdout.includes("#offer="), "output should not include a pairing offer"); + console.log("✓ daemon pair --json reports relay disabled\n"); } // Test 5: daemon status --json outputs valid JSON @@ -119,6 +124,90 @@ try { assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart"); console.log("✓ daemon restart starts and stop cleanup succeeds\n"); } + + // Test 8: status uses the running daemon as relay authority over local IPC + { + console.log("Test 8: daemon status probes live relay state over local IPC"); + const listen = + process.platform === "win32" + ? `\\\\.\\pipe\\paseo-status-${process.pid}-${Date.now()}` + : join(paseoHome, "status.sock"); + const start = await daemonCommand(["start", "--listen", listen, "--relay"]); + assert.strictEqual(start.exitCode, 0, `IPC daemon should start: ${start.stderr}`); + + const configPath = join(paseoHome, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf-8")); + config.daemon = { ...config.daemon, listen }; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); + const pidPath = join(paseoHome, "paseo.pid"); + const pidContents = await readFile(pidPath, "utf-8"); + await unlink(pidPath); + const status = await daemonCommand(["status", "--json"]); + const pairing = await daemonCommand(["pair", "--json"]); + await writeFile(pidPath, pidContents, "utf-8"); + assert.strictEqual(status.exitCode, 0, `IPC daemon status should succeed: ${status.stderr}`); + const payload = JSON.parse(status.stdout); + assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable"); + assert.strictEqual(payload.localDaemon, "stopped", "missing PID should report stopped locally"); + assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state"); + assert.strictEqual(pairing.exitCode, 0, `IPC daemon pairing should succeed: ${pairing.stderr}`); + const pairingPayload = JSON.parse(pairing.stdout); + assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state"); + assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer"); + + const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-")); + try { + await writeFile( + join(foreignHome, "config.json"), + `${JSON.stringify({ daemon: { listen } }, null, 2)}\n`, + "utf-8", + ); + const foreignPairing = await runLocalPaseo( + ["daemon", "pair", "--home", foreignHome, "--json"], + { PASEO_HOME: foreignHome }, + ); + assert.notStrictEqual( + foreignPairing.exitCode, + 0, + "pairing should reject a daemon owned by another home", + ); + assert( + foreignPairing.stderr.includes("different Paseo home"), + "pairing should explain the daemon identity mismatch", + ); + assert(!foreignPairing.stdout.includes("#offer="), "pairing should not expose another offer"); + } finally { + await rm(foreignHome, { recursive: true, force: true }); + } + + const cleanup = await daemonCommand(["stop", "--force"]); + assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after IPC status"); + console.log("✓ daemon status probes live relay state over local IPC\n"); + } + + // Test 9: --relay accepts an already-enabled persisted relay while stopped + { + console.log("Test 9: daemon pair --relay accepts persisted relay while stopped"); + const configPath = join(paseoHome, "config.json"); + const config = JSON.parse(await readFile(configPath, "utf-8")); + config.daemon = { + ...config.daemon, + relay: { ...config.daemon?.relay, enabled: true }, + }; + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); + + const pairing = await daemonCommand(["pair", "--relay", "--json"]); + + assert.strictEqual( + pairing.exitCode, + 0, + `persisted relay pairing should succeed while stopped: ${pairing.stderr}`, + ); + const payload = JSON.parse(pairing.stdout); + assert.strictEqual(payload.relayEnabled, true, "pairing should preserve persisted relay state"); + assert.match(payload.url, /#offer=/, "pairing should include the offline offer"); + console.log("✓ daemon pair --relay accepts persisted relay while stopped\n"); + } } finally { // Best-effort daemon cleanup in case assertions fail before explicit stop. await daemonCommand(["stop", "--force"]); diff --git a/packages/cli/tests/17-onboard.test.ts b/packages/cli/tests/17-onboard.test.ts index 2d006a28e5..3d8e641a26 100644 --- a/packages/cli/tests/17-onboard.test.ts +++ b/packages/cli/tests/17-onboard.test.ts @@ -15,7 +15,7 @@ const paseoHome = await mkdtemp(join(tmpdir(), "paseo-onboard-home-")); const port = await getAvailablePort(); try { - console.log("Test 1: `paseo` runs blocking onboarding and prints pairing info"); + console.log("Test 1: `paseo` runs blocking onboarding without implicit relay pairing"); const onboard = await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow(); @@ -24,12 +24,12 @@ try { 0, `onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`, ); - assert(onboard.stdout.includes("Scan to pair"), "onboard output should include scan header"); + assert(!onboard.stdout.includes("Scan to pair"), "onboard output should not include scan header"); + assert(!onboard.stdout.includes("#offer="), "onboard output should not include a pairing offer"); assert( - onboard.stdout.includes("Pairing link"), - "onboard output should include pairing link header", + onboard.stdout.includes("Daemon is running with relay off"), + "onboard output should explain the direct connection path", ); - assert(onboard.stdout.includes("#offer="), "onboard output should include pairing offer URL"); assert( onboard.stdout.includes("CLI quick reference"), "onboard output should include CLI quick reference", @@ -50,9 +50,28 @@ try { await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow(); assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`); assert(status.stdout.includes("running"), "daemon should be running when onboarding exits"); - console.log("✓ onboarding prints pairing info and waits for daemon readiness\n"); + console.log("✓ onboarding keeps relay disabled and waits for daemon readiness\n"); - console.log("Test 2: non-interactive onboarding persists voice disabled config"); + console.log("Test 2: --no-relay suppresses pairing for an already-running daemon"); + const enableRelay = + await $`PASEO_HOME=${paseoHome} npx paseo daemon pair --home ${paseoHome} --relay`.nothrow(); + assert.strictEqual(enableRelay.exitCode, 0, `relay enable should succeed: ${enableRelay.stderr}`); + assert(enableRelay.stdout.includes("#offer="), "relay enable should produce a pairing offer"); + + const noRelayOnboard = + await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} npx paseo --no-relay`.nothrow(); + assert.strictEqual( + noRelayOnboard.exitCode, + 0, + `--no-relay onboarding should succeed: ${noRelayOnboard.stderr}`, + ); + assert( + !noRelayOnboard.stdout.includes("#offer="), + "--no-relay onboarding should not include a pairing offer", + ); + console.log("✓ --no-relay suppresses pairing for an already-running daemon\n"); + + console.log("Test 3: non-interactive onboarding persists voice disabled config"); const configRaw = await readFile(join(paseoHome, "config.json"), "utf-8"); const config = JSON.parse(configRaw) as { features?: { diff --git a/packages/cli/tests/28-client-ipc-targets.test.ts b/packages/cli/tests/28-client-ipc-targets.test.ts index 7204935d70..24ceb9a632 100644 --- a/packages/cli/tests/28-client-ipc-targets.test.ts +++ b/packages/cli/tests/28-client-ipc-targets.test.ts @@ -26,6 +26,17 @@ console.log("=== CLI IPC Target Helpers ===\n"); console.log("✓ unix hosts resolve to ws+unix URLs\n"); } +{ + console.log("Test 1b: bare unix socket paths resolve at the connection boundary"); + const target = resolveDaemonTarget("/tmp/paseo.sock"); + assert.deepStrictEqual(target, { + type: "ipc", + url: "ws+unix:///tmp/paseo.sock:/ws", + socketPath: "/tmp/paseo.sock", + }); + console.log("✓ bare unix socket paths resolve at the connection boundary\n"); +} + { console.log("Test 2: pipe hosts preserve the Node socketPath transport form"); const target = resolveDaemonTarget("pipe://\\\\.\\pipe\\paseo-managed-test"); diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index 982a620066..faf4a7a101 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -78,12 +78,6 @@ interface DesktopDaemonLogs { contents: string; } -interface DesktopPairingOffer { - relayEnabled: boolean; - url: string | null; - qr: string | null; -} - function parseReleaseChannel( args: Record | undefined, ): AppReleaseChannel | undefined { @@ -219,18 +213,6 @@ function logDesktopDaemonLifecycle(message: string, details?: Record 0 ? trimmed : null; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function resolveDesktopAppVersion(): string { if (app.isPackaged) { return app.getVersion(); @@ -509,36 +491,6 @@ async function getCliDaemonStatus(): Promise { return await runExternalCliTextCommand(["daemon", "status"]); } -async function getDaemonPairing(): Promise { - const status = await resolveDesktopDaemonStatus(); - if (status.status !== "running") { - return { - relayEnabled: false, - url: null, - qr: null, - }; - } - - try { - const payload = await runExternalCliJsonCommand(["daemon", "pair", "--json"]); - if (!isRecord(payload)) { - throw new Error("Daemon pairing response was not an object."); - } - - return { - relayEnabled: payload.relayEnabled === true, - url: toTrimmedString(payload.url), - qr: toTrimmedString(payload.qr), - }; - } catch { - return { - relayEnabled: false, - url: null, - qr: null, - }; - } -} - async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> { const status = await resolveDesktopDaemonStatus(); if (status.status !== "running") { @@ -573,7 +525,6 @@ export function createDaemonCommandHandlers(): Record restartDaemon(), desktop_daemon_logs: () => getDaemonLogs(), desktop_app_logs: () => getDesktopAppLogs(), - desktop_daemon_pairing: () => getDaemonPairing(), desktop_get_system_idle_time: () => powerMonitor.getSystemIdleTime() * 1000, cli_daemon_status: () => getCliDaemonStatus(), write_attachment_base64: (args) => writeAttachmentBase64(args ?? {}), diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 256ea9baf8..24ee12e565 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -139,8 +139,15 @@ const MutableBrowserToolsConfigSchema = z enabled: z.boolean().default(false), }) .passthrough(); +const MutableRelayConfigSchema = z + .object({ + enabled: z.boolean(), + }) + .passthrough(); export const MutableDaemonConfigSchema = z .object({ + // COMPAT(relayConfig): added in v0.2.6, remove after 2027-01-31 when old daemons are unsupported. + relay: MutableRelayConfigSchema.optional(), mcp: z .object({ injectIntoAgents: z.boolean(), @@ -158,6 +165,7 @@ export const MutableDaemonConfigSchema = z export const MutableDaemonConfigPatchSchema = z .object({ + relay: MutableRelayConfigSchema.partial().optional(), mcp: MutableDaemonConfigSchema.shape.mcp.partial().optional(), browserTools: MutableBrowserToolsConfigSchema.partial().optional(), providers: z @@ -2808,6 +2816,8 @@ export const ServerInfoStatusPayloadSchema = z forgeSearch: z.boolean().optional(), // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. daemonStatusRpc: z.boolean().optional(), + // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. + relayConfig: z.boolean().optional(), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": z.boolean().optional(), // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 5f762fa24e..3509453322 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,7 +1,7 @@ import os from "node:os"; import http from "node:http"; import path from "node:path"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; @@ -12,8 +12,18 @@ import { hashDaemonPassword } from "./auth.js"; import { generateLocalPairingOffer } from "./pairing-offer.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; +import { DaemonClient } from "./test-utils/daemon-client.js"; import { isPlatform } from "../test-utils/platform.js"; import { findFreePort } from "./service-proxy.js"; +import type { + HubEnrollment, + HubEnrollmentResult, + HubRelationshipRemote, + HubRevocation, + HubSocketConnection, + HubSocketCredentials, + HubSocketEvents, +} from "./hub/relationship-remote.js"; interface HeldAgentClose { started: Promise; @@ -224,6 +234,103 @@ describe("paseo daemon bootstrap", () => { } }); + test("relay config changes during Hub enrollment reach the live runtime", async () => { + const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-startup-")); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); + await mkdir(paseoHome, { recursive: true }); + await writeFile( + path.join(paseoHome, "hub-relationship.json"), + `${JSON.stringify({ + version: 1, + state: "pending", + relationship: { + daemonId: "daemon-startup-race", + idempotencyKey: "enrollment-startup-race", + hubOrigin: "https://hub.test", + createdAt: "2026-07-31T00:00:00.000Z", + scopes: ["hub.execution.*"], + }, + credential: { secret: "credential" }, + enrollment: { token: "enrollment-token" }, + identity: { serverId: "server-startup-race", daemonPublicKey: "public-key" }, + })}\n`, + "utf-8", + ); + + let markEnrollmentStarted: () => void = () => undefined; + const enrollmentStarted = new Promise((resolve) => { + markEnrollmentStarted = resolve; + }); + let releaseEnrollment: () => void = () => undefined; + const enrollmentReleased = new Promise((resolve) => { + releaseEnrollment = resolve; + }); + const remote: HubRelationshipRemote = { + async enroll(input: HubEnrollment): Promise { + markEnrollmentStarted(); + await enrollmentReleased; + return { + daemonId: input.daemonId, + scopes: input.scopes, + webSocketUrl: "wss://hub.test/daemon", + }; + }, + async revoke(_input: HubRevocation): Promise {}, + openSocket(_input: HubSocketCredentials, _events: HubSocketEvents): HubSocketConnection { + return { close: () => undefined }; + }, + }; + const config: PaseoDaemonConfig = { + listen: "127.0.0.1:0", + paseoHome, + corsAllowedOrigins: [], + hostnames: true, + mcpEnabled: false, + staticDir, + mcpDebug: false, + agentClients: createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + relayEnabled: false, + relayEndpoint: "127.0.0.1:9", + relayUseTls: false, + appBaseUrl: "https://app.paseo.sh", + openai: undefined, + speech: undefined, + }; + const daemon = await createPaseoDaemon(config, pino({ level: "silent" }), { + hubRelationshipRemote: remote, + }); + const starting = daemon.start(); + let client: DaemonClient | null = null; + + try { + await enrollmentStarted; + const listenTarget = daemon.getListenTarget(); + if (!listenTarget || listenTarget.type !== "tcp") { + throw new Error("Expected daemon TCP listener during Hub enrollment"); + } + client = new DaemonClient({ + url: `ws://127.0.0.1:${listenTarget.port}/ws`, + appVersion: "0.1.82", + }); + await client.connect(); + await client.patchDaemonConfig({ relay: { enabled: true } }); + releaseEnrollment(); + await starting; + + const status = await client.getDaemonStatus(); + expect(status.relay?.enabled).toBe(true); + } finally { + releaseEnrollment(); + await starting.catch(() => undefined); + await client?.close().catch(() => undefined); + await daemon.stop().catch(() => undefined); + await rm(paseoHomeRoot, { recursive: true, force: true }); + await rm(staticDir, { recursive: true, force: true }); + } + }); + test("stops new connections and agent registrations before closing agents", async () => { const shutdown = await beginDaemonShutdownWithAgentClosing(); try { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 09ff21faad..f2027766f7 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -159,9 +159,8 @@ import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js"; import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js"; -import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; -import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; +import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js"; import type { PushNotificationSender } from "./push/notifications.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; @@ -395,6 +394,7 @@ export interface PaseoDaemonConfig { agentClients: Partial>; agentStoragePath: string; relayEnabled?: boolean; + relayEnabledMutable?: boolean; relayEndpoint?: string; relayPublicEndpoint?: string; relayUseTls?: boolean; @@ -449,6 +449,10 @@ export interface PaseoDaemonDependencies { hubRelationshipClock?: HubRelationshipClock; hubRelationshipRetryPolicy?: HubRelationshipRetryPolicy; createHubDaemonId?: () => string; + serverFeatureOverrides?: { + daemonStatusRpc?: boolean; + relayConfig?: boolean; + }; } function createBootstrapManagedProcessRegistry( @@ -507,6 +511,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae ); const initialConfig: MutableDaemonConfig = { + relay: { enabled: config.relayEnabled ?? true }, mcp: { injectIntoAgents: config.mcpInjectIntoAgents ?? true }, browserTools: { enabled: config.browserToolsEnabled ?? false }, providers, @@ -538,6 +543,7 @@ export async function createPaseoDaemon( config.paseoHome, createInitialMutableDaemonConfig(config), logger, + { relayEnabledMutable: config.relayEnabledMutable ?? true }, ); const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore); const browserToolsBroker = new BrowserToolsBroker({}); @@ -551,7 +557,7 @@ export async function createPaseoDaemon( void reconcileManagedProcessLedger(managedProcesses, logger).catch((error) => { logger.warn({ err: error }, "Failed to reconcile managed helper process ledger"); }); - let relayTransport: RelayTransportController | null = null; + let relayRuntime: RelayRuntime | null = null; const staticDir = config.staticDir; const downloadTokenTtlMs = config.downloadTokenTtlMs ?? 60000; @@ -1453,8 +1459,6 @@ export async function createPaseoDaemon( const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint; const relayUseTls = config.relayUseTls ?? relayEndpoint === "relay.paseo.sh:443"; const relayPublicUseTls = config.relayPublicUseTls ?? relayUseTls; - const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh"; - if (boundListenTarget.type === "tcp") { logger.info( { @@ -1489,7 +1493,12 @@ export async function createPaseoDaemon( config.paseoHome, daemonConfigStore, mcpBaseUrl, - { allowedOrigins, hostnames: configuredHostnames }, + { + allowedOrigins, + hostnames: configuredHostnames, + daemonStatusRpc: dependencies.serverFeatureOverrides?.daemonStatusRpc, + relayConfig: dependencies.serverFeatureOverrides?.relayConfig, + }, workspaceAutoName, config.auth, speechService, @@ -1526,47 +1535,39 @@ export async function createPaseoDaemon( worktreesRoot: config.worktreesRoot, appBaseUrl: config.appBaseUrl, desktopManaged: config.desktopManaged === true, - relay: { - enabled: relayEnabled, - endpoint: relayEndpoint, - publicEndpoint: relayPublicEndpoint, - useTls: relayUseTls, - publicUseTls: relayPublicUseTls, - }, + getRelayConfig: () => + relayRuntime?.getConfig() ?? { + enabled: daemonConfigStore.get().relay?.enabled ?? relayEnabled, + endpoint: relayEndpoint, + publicEndpoint: relayPublicEndpoint, + useTls: relayUseTls, + publicUseTls: relayPublicUseTls, + }, }, serviceProxyPublicBaseUrl, browserToolsBroker, hubRelationships, ); + relayRuntime = createRelayRuntime({ + config: { + enabled: relayEnabled, + endpoint: relayEndpoint, + publicEndpoint: relayPublicEndpoint, + useTls: relayUseTls, + publicUseTls: relayPublicUseTls, + }, + logger, + attachSocket: async (ws, metadata) => { + if (!wsServer) throw new Error("WebSocket server is not ready"); + await wsServer.attachExternalSocket(ws, metadata); + }, + serverId, + daemonKeyPair: daemonKeyPair.keyPair, + }); + daemonConfigStore.onFieldChange("relay.enabled", (value) => { + relayRuntime?.setEnabled(value === true); + }); await hubRelationships.start(); - - if (relayEnabled) { - const offer = await createConnectionOfferV2({ - serverId, - daemonPublicKeyB64: daemonKeyPair.publicKeyB64, - relay: { - endpoint: relayPublicEndpoint, - useTls: relayPublicUseTls, - }, - }); - - encodeOfferToFragmentUrl({ offer, appBaseUrl }); - - relayTransport?.stop().catch(() => undefined); - relayTransport = startRelayTransport({ - logger, - attachSocket: (ws, metadata) => { - if (!wsServer) { - throw new Error("WebSocket server not initialized"); - } - return wsServer.attachExternalSocket(ws, metadata); - }, - relayEndpoint, - relayUseTls, - serverId, - daemonKeyPair: daemonKeyPair.keyPair, - }); - } }; logAndResolve().then(resolve, reject); @@ -1613,7 +1614,7 @@ export async function createPaseoDaemon( terminalManager.killAll(); speechService.stop(); await scheduleService.stop().catch(() => undefined); - await relayTransport?.stop().catch(() => undefined); + await relayRuntime?.stop().catch(() => undefined); if (wsServer) { await wsServer.close(); } diff --git a/packages/server/src/server/config-relay.test.ts b/packages/server/src/server/config-relay.test.ts index f2e4744e51..3a21a96d60 100644 --- a/packages/server/src/server/config-relay.test.ts +++ b/packages/server/src/server/config-relay.test.ts @@ -21,6 +21,44 @@ describe("daemon relay config", () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); + test("preserves implicit relay-on for a legacy config without enabled", async () => { + const home = await createPaseoHome({ version: 1, daemon: { relay: {} } }); + expect(loadConfig(home, { env: {} }).relayEnabled).toBe(true); + }); + + test("keeps explicit persisted relay state and marks it mutable", async () => { + const home = await createPaseoHome({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + const config = loadConfig(home, { env: {} }); + expect(config.relayEnabled).toBe(false); + expect(config.relayEnabledMutable).toBe(true); + }); + + test("marks environment relay overrides immutable", async () => { + const home = await createPaseoHome({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + const config = loadConfig(home, { env: { PASEO_RELAY_ENABLED: "true" } }); + expect(config.relayEnabled).toBe(true); + expect(config.relayEnabledMutable).toBe(false); + }); + + test.each(["", "treu"])( + "ignores invalid relay override %j without locking config", + async (value) => { + const home = await createPaseoHome({ + version: 1, + daemon: { relay: { enabled: false } }, + }); + const config = loadConfig(home, { env: { PASEO_RELAY_ENABLED: value } }); + expect(config.relayEnabled).toBe(false); + expect(config.relayEnabledMutable).toBe(true); + }, + ); + test("loads relay TLS from env, persisted config, and hosted relay fallback", async () => { const persistedHome = await createPaseoHome({ version: 1, diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 7d9b71d7dd..e5979b529e 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -187,6 +187,7 @@ interface ResolveRelayInput { interface ResolvedRelay { enabled: boolean; + enabledMutable: boolean; endpoint: string; publicEndpoint: string; useTls: boolean; @@ -210,11 +211,11 @@ function resolveTlsFromEnv( } function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { + const environmentEnabled = parseBooleanEnv(input.env.PASEO_RELAY_ENABLED); + // COMPAT(relayOptInDefault): configs created before v0.2.6 may omit this field. + // Preserve their relay-on behavior until 2027-01-31; new homes materialize false. const enabled = - input.cliRelayEnabled ?? - parseBooleanEnv(input.env.PASEO_RELAY_ENABLED) ?? - input.persisted.daemon?.relay?.enabled ?? - true; + input.cliRelayEnabled ?? environmentEnabled ?? input.persisted.daemon?.relay?.enabled ?? true; const endpoint = input.env.PASEO_RELAY_ENDPOINT ?? input.persisted.daemon?.relay?.endpoint ?? @@ -235,7 +236,14 @@ function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { input.persisted.daemon?.relay?.publicUseTls, useTls, ); - return { enabled, endpoint, publicEndpoint, useTls, publicUseTls }; + return { + enabled, + enabledMutable: input.cliRelayEnabled === undefined && environmentEnabled === undefined, + endpoint, + publicEndpoint, + useTls, + publicUseTls, + }; } interface ResolvedVoiceLlm { @@ -506,6 +514,7 @@ export function loadConfig( staticDir: "public", agentClients: {}, relayEnabled: relay.enabled, + relayEnabledMutable: relay.enabledMutable, relayEndpoint: relay.endpoint, relayPublicEndpoint: relay.publicEndpoint, relayUseTls: relay.useTls, diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 4b4df23a52..e73150fec2 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -63,6 +63,110 @@ describe("DaemonConfigStore", () => { } }); + test("patch persists relay state and emits its field change", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }); + const changes: unknown[] = []; + store.onFieldChange("relay.enabled", (value) => changes.push(value)); + + store.patch({ relay: { enabled: true } }); + + expect(changes).toEqual([true]); + expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true); + }); + + test("rolls back config when a field transition fails", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }); + store.onFieldChange("relay.enabled", (enabled) => { + if (enabled === true) { + throw new Error("Relay transport failed to start"); + } + }); + + expect(() => store.patch({ relay: { enabled: true } })).toThrow( + "Relay transport failed to start", + ); + expect(store.get().relay?.enabled).toBe(false); + expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); + }); + + test("rejects relay patches when a launch override owns the setting", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore( + paseoHome, + { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }, + undefined, + { relayEnabledMutable: false }, + ); + + expect(() => store.patch({ relay: { enabled: true } })).toThrow( + "Relay is controlled by a daemon launch override", + ); + }); + + test("unrelated patches do not persist a one-launch relay override", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const persisted = loadPersistedConfig(paseoHome); + writeFileSync( + path.join(paseoHome, "config.json"), + `${JSON.stringify({ + ...persisted, + daemon: { ...persisted.daemon, relay: { enabled: false } }, + })}\n`, + ); + const store = new DaemonConfigStore( + paseoHome, + { + relay: { enabled: true }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }, + undefined, + { relayEnabledMutable: false }, + ); + + store.patch({ browserTools: { enabled: true } }); + + expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false); + }); + test("patch persists provider enabled flags into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 0805355322..f77fccc06e 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -27,6 +27,11 @@ export interface DaemonConfigChangeDetails { type ConfigListener = (config: MutableDaemonConfig, details: DaemonConfigChangeDetails) => void; type FieldChangeHandler = (value: unknown) => void; +interface AppliedFieldChange { + handler: FieldChangeHandler; + previousValue: unknown; +} + function getLogger(logger: LoggerLike | undefined): LoggerLike | undefined { return logger?.child({ module: "daemon-config-store" }); } @@ -162,11 +167,21 @@ export class DaemonConfigStore { private readonly logger: LoggerLike | undefined; private readonly changeListeners = new Set(); private readonly fieldChangeHandlers = new Map>(); + private readonly relayEnabledMutable: boolean; - constructor(paseoHome: string, initial: MutableDaemonConfig, logger?: LoggerLike) { + constructor( + paseoHome: string, + initial: MutableDaemonConfig, + logger?: LoggerLike, + options: { relayEnabledMutable?: boolean } = {}, + ) { this.paseoHome = paseoHome; this.logger = getLogger(logger); - this.current = MutableDaemonConfigSchema.parse(initial); + this.current = MutableDaemonConfigSchema.parse({ + ...initial, + relay: initial.relay ?? { enabled: true }, + }); + this.relayEnabledMutable = options.relayEnabledMutable ?? true; } public get(): MutableDaemonConfig { @@ -175,6 +190,11 @@ export class DaemonConfigStore { public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig { const parsedPatch = MutableDaemonConfigPatchSchema.parse(partial); + if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) { + throw new Error( + "Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.", + ); + } const { removeProviders = [], ...configPatch } = parsedPatch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch); @@ -194,24 +214,32 @@ export class DaemonConfigStore { return this.current; } - // Persist before updating in-memory state so that if persistence fails, - // runtime and disk stay consistent. - this.persistConfig(next, removedProviders); - if (!configChanged) { - return this.current; - } + const persistedBeforePatch = this.persistConfig(next, removedProviders); + if (!configChanged) return this.current; + const previous = this.current; + const appliedFieldChanges: AppliedFieldChange[] = []; this.current = next; - - for (const path of changedFieldPaths) { - const handlers = this.fieldChangeHandlers.get(path); - if (!handlers) { - continue; + try { + for (const path of changedFieldPaths) { + const handlers = this.fieldChangeHandlers.get(path); + if (!handlers) { + continue; + } + const value = getValueAtPath(next, path); + const previousValue = getValueAtPath(previous, path); + for (const handler of handlers) { + appliedFieldChanges.push({ handler, previousValue }); + handler(value); + } } - const value = getValueAtPath(next, path); - for (const handler of handlers) { - handler(value); + } catch (error) { + this.current = previous; + for (const change of appliedFieldChanges.toReversed()) { + change.handler(change.previousValue); } + savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger); + throw error; } const changeDetails: DaemonConfigChangeDetails = { removedProviders }; @@ -246,14 +274,19 @@ export class DaemonConfigStore { }; } - private persistConfig(config: MutableDaemonConfig, removeProviders: readonly string[]): void { + private persistConfig( + config: MutableDaemonConfig, + removeProviders: readonly string[], + ): PersistedConfig { const persisted = loadPersistedConfig(this.paseoHome, this.logger); const nextPersisted = mergeMutableConfigIntoPersistedConfig({ persisted, mutable: config, removeProviders, + persistRelayEnabled: this.relayEnabledMutable, }); savePersistedConfig(this.paseoHome, nextPersisted, this.logger); + return persisted; } } @@ -261,8 +294,12 @@ function mergeMutableConfigIntoPersistedConfig(params: { persisted: PersistedConfig; mutable: MutableDaemonConfig; removeProviders: readonly string[]; + persistRelayEnabled: boolean; }): PersistedConfig { - const { persisted, mutable, removeProviders } = params; + const { persisted, mutable, removeProviders, persistRelayEnabled } = params; + if (!mutable.relay) { + throw new Error("Mutable daemon config is missing relay state"); + } const browserToolsEnabled = readBrowserToolsEnabled(mutable); const metadataGenerationProviders = readMetadataGenerationProviders(mutable); const persistedProviderOverrides = omitProvidersFromOverrides( @@ -300,6 +337,14 @@ function mergeMutableConfigIntoPersistedConfig(params: { ...persisted, daemon: { ...persisted.daemon, + ...(persistRelayEnabled + ? { + relay: { + ...persisted.daemon?.relay, + enabled: mutable.relay.enabled, + }, + } + : {}), mcp: { ...persisted.daemon?.mcp, injectIntoAgents: mutable.mcp.injectIntoAgents, diff --git a/packages/server/src/server/daemon-worker.ts b/packages/server/src/server/daemon-worker.ts index b0909f0489..4656523ef7 100644 --- a/packages/server/src/server/daemon-worker.ts +++ b/packages/server/src/server/daemon-worker.ts @@ -84,8 +84,13 @@ function bootstrapFromEnvironment(): BootstrapResult { } function applyCliFlagOverrides(config: ReturnType): void { + if (process.argv.includes("--relay")) { + config.relayEnabled = true; + config.relayEnabledMutable = false; + } if (process.argv.includes("--no-relay")) { config.relayEnabled = false; + config.relayEnabledMutable = false; } if (process.argv.includes("--relay-use-tls")) { config.relayUseTls = true; diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index aff3e65352..140bc0f40e 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -648,6 +648,16 @@ describe("PersistedConfigSchema voice mode config", () => { }); describe("loadPersistedConfig", () => { + test("materializes relay disabled for a new Paseo home", () => { + const home = createTempHome(); + try { + const config = loadPersistedConfig(home); + expect(config.daemon?.relay?.enabled).toBe(false); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test("accepts the documented config schema marker", () => { const home = createTempHome(); const configPath = path.join(home, "config.json"); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 79a84a416e..3d08b28ef2 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -334,7 +334,7 @@ const DEFAULT_PERSISTED_CONFIG = PersistedConfigSchema.parse({ allowedOrigins: ["https://app.paseo.sh"], }, relay: { - enabled: true, + enabled: false, }, }, app: { diff --git a/packages/server/src/server/relay-runtime.test.ts b/packages/server/src/server/relay-runtime.test.ts new file mode 100644 index 0000000000..4087fba889 --- /dev/null +++ b/packages/server/src/server/relay-runtime.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test, vi } from "vitest"; +import pino from "pino"; +import { generateKeyPair } from "@getpaseo/relay"; +import { createRelayRuntime } from "./relay-runtime.js"; +import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; + +describe("RelayRuntime", () => { + test("starts and stops transport as enabled state changes", async () => { + const stops: Array> = []; + const starts: string[] = []; + const startTransport: typeof startRelayTransport = (options) => { + starts.push(options.relayEndpoint); + const stop = vi.fn(async () => undefined); + stops.push(stop); + return { stop } satisfies RelayTransportController; + }; + const runtime = createRelayRuntime({ + config: { + enabled: false, + endpoint: "relay.example.test:443", + publicEndpoint: "relay.example.test:443", + useTls: true, + publicUseTls: true, + }, + logger: pino({ level: "silent" }), + attachSocket: async () => undefined, + serverId: "relay-runtime-test", + daemonKeyPair: generateKeyPair(), + startTransport, + }); + + expect(starts).toEqual([]); + runtime.setEnabled(true); + runtime.setEnabled(true); + expect(starts).toEqual(["relay.example.test:443"]); + expect(runtime.getConfig().enabled).toBe(true); + + runtime.setEnabled(false); + await vi.waitFor(() => expect(stops[0]).toHaveBeenCalledOnce()); + expect(runtime.getConfig().enabled).toBe(false); + }); + + test("keeps relay disabled when transport startup fails", () => { + const runtime = createRelayRuntime({ + config: { + enabled: false, + endpoint: "invalid-endpoint", + publicEndpoint: "invalid-endpoint", + useTls: false, + publicUseTls: false, + }, + logger: pino({ level: "silent" }), + attachSocket: async () => undefined, + serverId: "relay-runtime-test", + daemonKeyPair: generateKeyPair(), + startTransport: () => { + throw new Error("Invalid relay endpoint"); + }, + }); + + expect(() => runtime.setEnabled(true)).toThrow("Invalid relay endpoint"); + expect(runtime.getConfig().enabled).toBe(false); + }); +}); diff --git a/packages/server/src/server/relay-runtime.ts b/packages/server/src/server/relay-runtime.ts new file mode 100644 index 0000000000..e9f2ccf158 --- /dev/null +++ b/packages/server/src/server/relay-runtime.ts @@ -0,0 +1,78 @@ +import type pino from "pino"; +import type { KeyPair } from "@getpaseo/relay/e2ee"; +import type { ExternalSocketMetadata } from "./websocket-server.js"; +import { + startRelayTransport, + type RelaySocketLike, + type RelayTransportController, +} from "./relay-transport.js"; + +export interface RelayRuntimeConfig { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; +} + +interface RelayRuntimeOptions { + config: RelayRuntimeConfig; + logger: pino.Logger; + attachSocket(ws: RelaySocketLike, metadata?: ExternalSocketMetadata): Promise; + serverId: string; + daemonKeyPair: KeyPair; + startTransport?: typeof startRelayTransport; +} + +export interface RelayRuntime { + getConfig(): RelayRuntimeConfig; + setEnabled(enabled: boolean): void; + stop(): Promise; +} + +export function createRelayRuntime(options: RelayRuntimeOptions): RelayRuntime { + const startTransport = options.startTransport ?? startRelayTransport; + let config = options.config; + let transport: RelayTransportController | null = null; + + function start(): void { + if (transport) return; + transport = startTransport({ + logger: options.logger, + attachSocket: options.attachSocket, + relayEndpoint: config.endpoint, + relayUseTls: config.useTls, + serverId: options.serverId, + daemonKeyPair: options.daemonKeyPair, + }); + } + + function setEnabled(enabled: boolean): void { + if (config.enabled === enabled) return; + if (enabled) { + start(); + config = { ...config, enabled: true }; + return; + } + config = { ...config, enabled: false }; + const current = transport; + transport = null; + void current?.stop().catch((error) => { + options.logger.warn({ err: error }, "Failed to stop relay transport"); + }); + } + + async function stop(): Promise { + const current = transport; + transport = null; + await current?.stop(); + } + + if (config.enabled) start(); + + return { + getConfig: () => config, + setEnabled, + stop, + }; +} diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index b60b01bd00..ddf1a3c234 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -11,7 +11,7 @@ import { buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints"; import type { ExternalSocketMetadata } from "./websocket-server.js"; import { createEncryptedRelaySocket } from "./websocket/encrypted-relay-socket.js"; -interface RelayTransportOptions { +export interface RelayTransportOptions { logger: pino.Logger; attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise; relayEndpoint: string; // "host:port" @@ -25,7 +25,7 @@ export interface RelayTransportController { stop: () => Promise; } -interface RelaySocketLike { +export interface RelaySocketLike { readyState: number; bufferedAmount?: number; send: (data: string | Uint8Array | ArrayBuffer, callback?: (error?: Error) => void) => void; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 9a66c3ec34..5677750165 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -1425,7 +1425,7 @@ describe("daemon status + pairing RPC", () => { paseoHome: makeHome(), serverId: "srv-test", daemonVersion: "9.9.9", - daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null }, + daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null }, agentManager: { listProviderAvailability: vi.fn().mockResolvedValue([ { provider: "claude", available: true }, @@ -1464,7 +1464,7 @@ describe("daemon status + pairing RPC", () => { paseoHome: makeHome(), serverId: "srv-test", daemonVersion: "9.9.9", - daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null }, + daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null }, agentManager: { listProviderAvailability: vi.fn().mockRejectedValue(new Error("provider listing failed")), }, @@ -1500,13 +1500,13 @@ describe("daemon status + pairing RPC", () => { paseoHome: makeHome(), daemonRuntimeConfig: { listen: "127.0.0.1:6767", - relay: { + getRelayConfig: () => ({ enabled: false, endpoint: "relay.paseo.sh:443", publicEndpoint: "relay.paseo.sh:443", useTls: true, publicUseTls: true, - }, + }), }, }); diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts index 661439bad4..5bb3832061 100644 --- a/packages/server/src/server/session/daemon/daemon-session.test.ts +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -127,7 +127,7 @@ describe("DaemonSession", () => { const { subsystem, emitted } = makeSubsystem({ serverId: "srv-1", daemonVersion: "1.2.3", - daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null }, + daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null }, listProviderAvailability: async () => [ { provider: "claude", available: true, error: null }, { provider: "codex", available: false, error: "boom" }, @@ -161,7 +161,7 @@ describe("DaemonSession", () => { const { subsystem, emitted } = makeSubsystem({ serverId: "srv-1", daemonVersion: "1.2.3", - daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null }, + daemonRuntimeConfig: { listen: "127.0.0.1:6767", getRelayConfig: () => null }, listProviderAvailability: async () => { throw new Error("provider listing failed"); }, @@ -191,13 +191,13 @@ describe("DaemonSession", () => { const { subsystem, emitted } = makeSubsystem({ daemonRuntimeConfig: { listen: "127.0.0.1:6767", - relay: { + getRelayConfig: () => ({ enabled: false, endpoint: "relay.paseo.sh:443", publicEndpoint: "relay.paseo.sh:443", useTls: true, publicUseTls: true, - }, + }), }, }); @@ -219,13 +219,13 @@ describe("DaemonSession", () => { daemonRuntimeConfig: { listen: "127.0.0.1:6767", appBaseUrl: "https://app.example.test", - relay: { + getRelayConfig: () => ({ enabled: true, endpoint: "relay.example.test:443", publicEndpoint: "relay.example.test:443", useTls: true, publicUseTls: true, - }, + }), }, }); @@ -246,19 +246,53 @@ describe("DaemonSession", () => { expect(typeof message.payload.qr).toBe("string"); }); + test("pairing offer reads relay state at request time", async () => { + let enabled = false; + const { subsystem, emitted } = makeSubsystem({ + daemonRuntimeConfig: { + listen: "127.0.0.1:6767", + appBaseUrl: "https://app.example.test", + getRelayConfig: () => ({ + enabled, + endpoint: "relay.example.test:443", + publicEndpoint: "relay.example.test:443", + useTls: true, + publicUseTls: true, + }), + }, + }); + + await subsystem.handleGetPairingOfferRequest({ + type: "daemon.get_pairing_offer.request", + requestId: "disabled", + }); + enabled = true; + await subsystem.handleGetPairingOfferRequest({ + type: "daemon.get_pairing_offer.request", + requestId: "enabled", + }); + + const pairingResponses = emitted.filter( + (message) => message.type === "daemon.get_pairing_offer.response", + ); + expect(pairingResponses[0]?.payload.relayEnabled).toBe(false); + expect(pairingResponses[1]?.payload.relayEnabled).toBe(true); + expect(pairingResponses[1]?.payload.url).toContain("#offer="); + }); + test("diagnostics includes a log tail and redacts connection secrets", async () => { const { subsystem, emitted, paseoHome } = makeSubsystem({ serverId: "srv-1", daemonVersion: "1.2.3", daemonRuntimeConfig: { listen: "127.0.0.1:6767", - relay: { + getRelayConfig: () => ({ enabled: true, endpoint: "relay.secret.test:443", publicEndpoint: "relay.secret.test:443", useTls: true, publicUseTls: true, - }, + }), }, }); writeFileSync( diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts index 7142ce8f8c..42f460c34f 100644 --- a/packages/server/src/server/session/daemon/daemon-session.ts +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -17,7 +17,7 @@ export interface DaemonRuntimeConfig { worktreesRoot?: string; appBaseUrl?: string; desktopManaged?: boolean; - relay: { + getRelayConfig(): { enabled: boolean; endpoint: string; publicEndpoint: string; @@ -169,7 +169,7 @@ export class DaemonSession { nodePath: process.execPath, startedAt: pidInfo?.startedAt ?? null, listen: this.daemonRuntimeConfig?.listen ?? null, - relay: this.daemonRuntimeConfig?.relay ?? null, + relay: this.daemonRuntimeConfig?.getRelayConfig() ?? null, providers, }, }); @@ -196,10 +196,10 @@ export class DaemonSession { msg: Extract, ): Promise { try { - const relay = this.daemonRuntimeConfig?.relay; + const relay = this.daemonRuntimeConfig?.getRelayConfig(); const pairing = await generateLocalPairingOffer({ paseoHome: this.paseoHome, - relayEnabled: relay?.enabled ?? true, + relayEnabled: relay?.enabled ?? false, relayEndpoint: relay?.endpoint, relayPublicEndpoint: relay?.publicEndpoint, relayUseTls: relay?.useTls, diff --git a/packages/server/src/server/session/daemon/diagnostics.ts b/packages/server/src/server/session/daemon/diagnostics.ts index eccffcd23d..99ca2fe61b 100644 --- a/packages/server/src/server/session/daemon/diagnostics.ts +++ b/packages/server/src/server/session/daemon/diagnostics.ts @@ -133,7 +133,7 @@ function collectProcessEntries(options: DaemonDiagnosticsOptions): DiagnosticEnt } function collectRuntimeConfigEntries(options: DaemonDiagnosticsOptions): DiagnosticEntry[] { - const relay = options.daemonRuntimeConfig?.relay ?? null; + const relay = options.daemonRuntimeConfig?.getRelayConfig() ?? null; return [ { label: "Listen", value: formatListenKind(options.daemonRuntimeConfig?.listen ?? null) }, { label: "Relay enabled", value: relay ? String(relay.enabled) : "false" }, @@ -536,8 +536,8 @@ export function redactDiagnostic( let redacted = value; const sensitiveValues = [ options?.daemonRuntimeConfig?.listen, - options?.daemonRuntimeConfig?.relay?.endpoint, - options?.daemonRuntimeConfig?.relay?.publicEndpoint, + options?.daemonRuntimeConfig?.getRelayConfig()?.endpoint, + options?.daemonRuntimeConfig?.getRelayConfig()?.publicEndpoint, ].filter((item): item is string => Boolean(item)); for (const sensitive of sensitiveValues) { diff --git a/packages/server/src/server/test-utils/outdated-daemon-process.ts b/packages/server/src/server/test-utils/outdated-daemon-process.ts index b09aa4bf50..d4beec4261 100644 --- a/packages/server/src/server/test-utils/outdated-daemon-process.ts +++ b/packages/server/src/server/test-utils/outdated-daemon-process.ts @@ -12,6 +12,8 @@ async function main(): Promise { corsAllowedOrigins: [`http://localhost:${metroPort}`], daemonVersion: "0.0.0", desktopManaged: process.env.E2E_DESKTOP_MANAGED === "1", + daemonStatusRpcCapability: process.env.E2E_DAEMON_STATUS_RPC_CAPABILITY !== "0", + relayConfigCapability: process.env.E2E_RELAY_CONFIG_CAPABILITY !== "0", }); const serverId = (await readFile(path.join(daemon.paseoHome, "server-id"), "utf8")).trim(); diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 284059b66c..ed4708ec7b 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -27,6 +27,8 @@ interface TestPaseoDaemonOptions { relayEndpoint?: string; relayUseTls?: boolean; relayPublicUseTls?: boolean; + daemonStatusRpcCapability?: boolean; + relayConfigCapability?: boolean; agentClients?: Partial>; providerOverrides?: PaseoDaemonConfig["providerOverrides"]; paseoHomeRoot?: string; @@ -92,7 +94,12 @@ export async function createTestPaseoDaemon( for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options); const logger = options.logger ?? pino({ level: "silent" }); - const daemon = await createPaseoDaemon(config, logger); + const daemon = await createPaseoDaemon(config, logger, { + serverFeatureOverrides: { + daemonStatusRpc: options.daemonStatusRpcCapability, + relayConfig: options.relayConfigCapability, + }, + }); try { await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS); const listenTarget = daemon.getListenTarget(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ebcc032409..6c5886fed9 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -131,6 +131,8 @@ interface WebSocketConnectionIdentity { interface WebSocketServerConfig { allowedOrigins: Set; hostnames?: HostnamesConfig; + daemonStatusRpc?: boolean; + relayConfig?: boolean; } type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; @@ -554,6 +556,8 @@ export class VoiceAssistantWebSocketServer { private readonly hubRelationships: HubRelationshipManagement | null; private readonly browserToolsRegistrations = new Map(); private acceptingConnections = true; + private readonly advertiseDaemonStatusRpc: boolean; + private readonly advertiseRelayConfig: boolean; constructor( server: HTTPServer, @@ -601,6 +605,8 @@ export class VoiceAssistantWebSocketServer { hubRelationships?: HubRelationshipManagement | null, ) { this.logger = logger.child({ module: "websocket-server" }); + this.advertiseDaemonStatusRpc = wsConfig.daemonStatusRpc !== false; + this.advertiseRelayConfig = wsConfig.relayConfig !== false; this.serverId = serverId; if (typeof daemonVersion !== "string" || daemonVersion.trim().length === 0) { throw new MissingDaemonVersionError(); @@ -1520,7 +1526,9 @@ export class VoiceAssistantWebSocketServer { // COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28. forgeSearch: true, // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. - daemonStatusRpc: true, + ...(this.advertiseDaemonStatusRpc ? { daemonStatusRpc: true } : {}), + // COMPAT(relayConfig): added in v0.2.6, remove gate after 2027-01-31. + ...(this.advertiseRelayConfig ? { relayConfig: true } : {}), // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. "terminal-restore-modes": true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. diff --git a/public-docs/cli.md b/public-docs/cli.md index 6d1e6394af..1e331525ab 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -200,9 +200,13 @@ Use `PASEO_HOME` to run multiple isolated daemon instances. Get an offer URL from the daemon you want to control: ```bash -paseo daemon pair --json # prints { url, qr, ... } +paseo daemon pair # asks before enabling relay, then prints the QR and link +paseo daemon pair --relay # enables relay without prompting +paseo daemon pair --json # structured output; never prompts ``` +Relay is off for new installations. In non-interactive or JSON mode, a disabled relay returns a `RELAY_DISABLED` error; pass `--relay` to provide explicit consent. Relay pairing is end-to-end encrypted. See [Security](/docs/security). + Use it from anywhere: ```bash diff --git a/public-docs/configuration.md b/public-docs/configuration.md index 50decc79c4..474d5ff9b4 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -187,11 +187,30 @@ A `password=` in the URI always wins over the env var, so you can keep `PASEO_PA In the mobile app, enter the password in the direct connection setup screen. +## Relay + +New homes write `daemon.relay.enabled: false`. Paseo asks before enabling relay when you pair a device; existing homes keep their saved value. See [Security](/docs/security) for relay encryption and direct connection alternatives. + +Set the persisted value in `config.json`: + +```json +{ + "daemon": { + "relay": { + "enabled": true + } + } +} +``` + +`PASEO_RELAY_ENABLED=true|false` overrides the persisted value for that daemon launch. The matching `paseo daemon start --relay` and `--no-relay` flags have the same authority. Remove the launch override before changing relay from Paseo Desktop or `paseo daemon pair --relay`. + ## Common env vars - `PASEO_HOME`, set Paseo home directory - `PASEO_PASSWORD`, on the daemon, the password to require (plaintext, hashed at startup); on the CLI, the password used to connect when the host URI doesn't include one - `PASEO_LISTEN`, override `daemon.listen` +- `PASEO_RELAY_ENABLED`, enable or disable the outbound relay for this daemon launch - `PASEO_HOSTNAMES`, override/extend `daemon.hostnames` - `PASEO_ALLOWED_HOSTS`, deprecated alias for `PASEO_HOSTNAMES` - `PASEO_WEB_UI_ENABLED`, enable or disable the daemon-served web UI diff --git a/public-docs/index.md b/public-docs/index.md index 0045052231..b092e5cd72 100644 --- a/public-docs/index.md +++ b/public-docs/index.md @@ -14,7 +14,7 @@ Paseo runs your coding agents on your machine and gives you a mobile, desktop, w Download from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open it and you're done. -The desktop app bundles its own daemon and starts it automatically, no separate install required. On first launch you'll see a brief startup screen, then connect from your phone using **Settings → your host → Connections → Pair a device**. +The desktop app bundles its own daemon and starts it automatically, no separate install required. On first launch you'll see a brief startup screen, then connect from your phone using **Settings → your host → Pair Device**. ## Server / CLI @@ -25,7 +25,7 @@ npm install -g @getpaseo/cli paseo ``` -Paseo prints a QR code in the terminal. Scan it from the mobile app, or enter the daemon address manually from another client. +Paseo starts the daemon locally, then asks whether to enable the end-to-end encrypted relay and print a pairing QR code. If you decline, enter the daemon address manually over TCP, Tailscale, or another VPN. The daemon can also serve the browser web app itself, so you can use the full UI without the hosted app. See [Self-hosting the web UI](/docs/web-ui). diff --git a/public-docs/security.md b/public-docs/security.md index 56c27099e7..4b58dc1127 100644 --- a/public-docs/security.md +++ b/public-docs/security.md @@ -25,6 +25,8 @@ Clients connect to the daemon over WebSocket. There are two ways to establish th The relay is the simplest way to connect from your phone. It requires no VPN setup, no port forwarding, and no firewall configuration. The daemon can stay bound to localhost or a socket file, it connects _outbound_ to the relay, and your phone meets it there. The official relay server is the open-source Elixir service at [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay). +Relay is off on new installations. When you pair a device from `paseo`, `paseo daemon pair`, or Paseo Desktop, Paseo asks before enabling it. Choosing not to enable relay leaves the daemon available for direct TCP, Tailscale, or other VPN connections and does not create a pairing QR code. Use `--relay` with the CLI pairing or startup command to opt in without an interactive prompt. + > **The relay is designed to be untrusted.** All traffic between your phone and daemon is end-to-end encrypted. The relay server cannot read your messages, see your code, or modify traffic without detection. Even if the relay is compromised, your data remains protected. ### How it works diff --git a/public-docs/web-ui.md b/public-docs/web-ui.md index eecd57c1f7..445b1eb192 100644 --- a/public-docs/web-ui.md +++ b/public-docs/web-ui.md @@ -76,7 +76,7 @@ By default the daemon listens on `127.0.0.1:6767`, reachable only from the same paseo daemon start --web-ui --listen 0.0.0.0:6767 ``` -> **Anyone who can reach the listening address can use your agents.** Before you bind beyond localhost, set a password and review your host allowlist. The relay (the default mobile pairing path) avoids this entirely by keeping the daemon bound to localhost, see [Security](/docs/security). +> **Anyone who can reach the listening address can use your agents.** Before you bind beyond localhost, set a password and review your host allowlist. The relay pairing path avoids this entirely by keeping the daemon bound to localhost, see [Security](/docs/security). Two things to configure when you expose the daemon directly: From 9d19e903236847cb89c78c051982955e8576e9e0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 31 Jul 2026 23:17:47 +0200 Subject: [PATCH 42/46] Show worktree names in workspace hover cards (#2711) * fix(app): show worktree names in workspace hover cards Managed worktree paths exposed Paseo's internal directory layout instead of the useful worktree name. Carry the owned worktree root through the sidebar model while preserving the full workspace path as the copy target. * fix(workspaces): source owned worktree names from daemon The daemon owns worktree path semantics, so expose the managed worktree slug and let the sidebar model produce a display-ready label. This keeps path parsing out of the hover-card component while preserving the full path for copying. * fix(app): preserve worktree labels in replica cache --- packages/app/e2e/sidebar-workspace.spec.ts | 61 +++++++++++++++++-- .../sidebar/sidebar-projection.test.ts | 2 + .../src/components/workspace-hover-card.tsx | 8 +-- .../hooks/sidebar-status-view-model.test.ts | 3 +- .../sidebar-workspaces-view-model.test.ts | 21 +++++++ .../hooks/sidebar-workspaces-view-model.ts | 5 ++ .../hooks/use-sidebar-shortcut-model.test.tsx | 1 + .../src/runtime/replica-cache/index.test.ts | 12 +++- .../app/src/runtime/replica-cache/index.ts | 1 + packages/app/src/stores/session-store.ts | 2 + .../utils/sidebar-project-row-model.test.ts | 1 + .../app/src/utils/sidebar-shortcuts.test.ts | 1 + packages/protocol/src/messages.ts | 3 + .../protocol/src/messages.workspaces.test.ts | 20 ++++++ packages/server/src/server/session.ts | 9 ++- 15 files changed, 137 insertions(+), 13 deletions(-) diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts index 69c86af848..793473f162 100644 --- a/packages/app/e2e/sidebar-workspace.spec.ts +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -47,6 +47,51 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo return row; } +async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) { + const row = await waitForSidebarWorkspace(page, workspaceId); + await row.hover(); + + const hoverCard = page.getByRole("menu", { name: "Workspace scripts" }); + await expect(hoverCard).toBeVisible({ timeout: 30_000 }); + return hoverCard; +} + +interface PaseoOwnedWorktree { + projectName: string; + workspaceId: string; + worktreeSlug: string; +} + +async function withPaseoOwnedWorktree( + run: (workspace: PaseoOwnedWorktree) => Promise, +): Promise { + const project = await seedWorkspace({ repoPrefix: "sidebar-hover-owned-worktree-" }); + const worktreeSlug = "hover-card-owned-worktree"; + + try { + const created = await project.client.createWorkspace({ + source: { + kind: "worktree", + cwd: project.repoPath, + projectId: project.projectId, + worktreeSlug, + }, + }); + if (!created.workspace) { + throw new Error(created.error ?? "Failed to create Paseo-owned worktree"); + } + expect(path.basename(created.workspace.workspaceDirectory)).toBe(worktreeSlug); + + await run({ + projectName: path.basename(project.repoPath), + workspaceId: created.workspace.id, + worktreeSlug, + }); + } finally { + await project.cleanup(); + } +} + test.describe("Sidebar workspace list", () => { test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => { const workspace = await seedWorkspace({ @@ -146,17 +191,23 @@ test.describe("Sidebar workspace list", () => { await gotoAppShell(page); await waitForSidebarProject(page, path.basename(workspace.repoPath)); - const row = await waitForSidebarWorkspace(page, workspace.workspaceId); - await row.hover(); - - const hoverCard = page.getByTestId("workspace-hover-card"); - await expect(hoverCard).toBeVisible({ timeout: 30_000 }); + const hoverCard = await openWorkspaceHoverCard(page, workspace.workspaceId); await expect(page.getByTestId("hover-card-workspace-host")).toHaveText("localhost"); await expect(hoverCard).not.toContainText(/\b(Online|Connecting|Offline|Error|Idle)\b/); } finally { await workspace.cleanup(); } }); + + test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => { + await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => { + await gotoAppShell(page); + await waitForSidebarProject(page, projectName); + await openWorkspaceHoverCard(page, workspaceId); + + await expect(page.getByTestId("hover-card-workspace-cwd")).toHaveText(worktreeSlug); + }); + }); }); test.describe("Mobile sidebar panelState transition", () => { diff --git a/packages/app/src/components/sidebar/sidebar-projection.test.ts b/packages/app/src/components/sidebar/sidebar-projection.test.ts index 9338d55a47..b5d15d2451 100644 --- a/packages/app/src/components/sidebar/sidebar-projection.test.ts +++ b/packages/app/src/components/sidebar/sidebar-projection.test.ts @@ -19,6 +19,8 @@ function makeWorkspace(id: string, statusBucket: SidebarWorkspaceEntry["statusBu }; const entry: SidebarWorkspaceEntry = { ...placement, + workspaceDirectory: "", + workspaceDirectoryLabel: "", title: null, currentBranch: null, statusBucket, diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index 37cd114fc6..6d7e13ac9a 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -34,7 +34,6 @@ import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { PrHint } from "@/git/use-pr-status-query"; import { openExternalUrl } from "@/utils/open-external-url"; -import { shortenPath } from "@/utils/shorten-path"; import { copyToClipboard } from "@/utils/copy-to-clipboard"; import { PrBadge } from "@/components/sidebar-workspace-list"; import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; @@ -214,7 +213,6 @@ function WorkspaceHoverCardContent({ contentRef: React.RefObject; }): ReactElement | null { const { t } = useTranslation(); - const cwdDisplay = shortenPath(workspace.workspaceDirectory); const bottomSheetInternal = useBottomSheetModalInternal(true); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); @@ -297,11 +295,11 @@ function WorkspaceHoverCardContent({ testID="hover-card-workspace-branch" /> ) : null} - {cwdDisplay ? ( + {workspace.workspaceDirectoryLabel ? ( diff --git a/packages/app/src/hooks/sidebar-status-view-model.test.ts b/packages/app/src/hooks/sidebar-status-view-model.test.ts index c9e026da8a..62dc7142a8 100644 --- a/packages/app/src/hooks/sidebar-status-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-status-view-model.test.ts @@ -17,7 +17,8 @@ function ws( projectViewKey: input.projectViewKey ?? "proj", projectName: input.projectName ?? "Project", projectRootPath: input.projectRootPath, - workspaceDirectory: input.workspaceDirectory, + workspaceDirectory: input.workspaceDirectory ?? "", + workspaceDirectoryLabel: input.workspaceDirectoryLabel ?? "", projectKind: input.projectKind ?? "git", workspaceKind: input.workspaceKind ?? "worktree", name: input.name ?? "main", diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index d108f9d862..332f90a7d8 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -64,6 +64,27 @@ describe("createSidebarWorkspaceEntry forge threading", () => { }); }); +describe("createSidebarWorkspaceEntry workspace directory label", () => { + it("uses the daemon-provided slug for a Paseo-owned worktree", () => { + const descriptor = workspaceWithForge(undefined, "https://github.com/acme/repo/pull/42"); + descriptor.workspaceDirectory = "/worktrees/feature/packages/app"; + descriptor.worktreeSlug = "feature"; + + const entry = createSidebarWorkspaceEntry({ serverId: "srv", workspace: descriptor }); + + expect(entry.workspaceDirectoryLabel).toBe("feature"); + }); + + it("shortens the workspace path when the daemon omits a worktree slug", () => { + const descriptor = workspaceWithForge(undefined, "https://github.com/acme/repo/pull/42"); + descriptor.workspaceDirectory = "/home/alice/external/feature"; + + const entry = createSidebarWorkspaceEntry({ serverId: "srv", workspace: descriptor }); + + expect(entry.workspaceDirectoryLabel).toBe("~/external/feature"); + }); +}); + interface OrderedItem { key: string; } diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 330822799d..5c73b03561 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -8,6 +8,7 @@ import type { WorkspaceStructureProject, } from "@/projects/workspace-structure"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; +import { shortenPath } from "@/utils/shorten-path"; import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; @@ -34,6 +35,8 @@ export interface SidebarStatusWorkspacePlacement extends SidebarWorkspacePlaceme } export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement { + workspaceDirectory: string; + workspaceDirectoryLabel: string; // Raw user-set title (null when the name is derived from branch/directory). // Prefills the rename input and signals whether a reset is available. title: string | null; @@ -155,6 +158,8 @@ export function createSidebarWorkspaceEntry(input: { projectName: projectNameForWorkspace(input.workspace), projectRootPath: input.workspace.projectRootPath, workspaceDirectory: input.workspace.workspaceDirectory, + workspaceDirectoryLabel: + input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory), projectKind: input.workspace.projectKind, workspaceKind: input.workspace.workspaceKind, name: input.workspace.name, diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx index c54fa0cd2d..ebb0c5e81c 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.test.tsx @@ -21,6 +21,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt projectName: projectKey, projectRootPath: `/repo/${projectKey}`, workspaceDirectory: `/repo/${projectKey}/${workspaceId}`, + workspaceDirectoryLabel: `/repo/${projectKey}/${workspaceId}`, projectKind: "git", workspaceKind: "worktree", name: workspaceId, diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index bf3344de0e..cf8f9978dc 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -94,7 +94,16 @@ function seedSession(): void { store.setAgents(SERVER_ID, new Map([["agent-1", agent("agent-1")]])); store.setWorkspaces( SERVER_ID, - new Map([["workspace-1", normalizeWorkspaceDescriptor(workspace())]]), + new Map([ + [ + "workspace-1", + normalizeWorkspaceDescriptor({ + ...workspace(), + workspaceKind: "worktree", + worktreeSlug: "owned-worktree", + }), + ], + ]), ); store.setProjects(SERVER_ID, [ normalizeProjectDescriptor({ @@ -172,6 +181,7 @@ describe("ReplicaCache", () => { expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-1"]); expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date); expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); + expect(session?.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); expect(session?.agentAuthoritativeHistoryApplied.get("agent-1")).toBe(true); expect(session?.agentTimelineCursor.get("agent-1")).toEqual({ diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 5158e7d01f..4b4a254396 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -199,6 +199,7 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor projectCustomIconRevision: workspace.projectCustomIconRevision ?? null, projectRootPath: workspace.projectRootPath, workspaceDirectory: workspace.workspaceDirectory, + worktreeSlug: workspace.worktreeSlug, projectKind: workspace.projectKind, workspaceKind: workspace.workspaceKind, name: workspace.name, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 5df9b9cc98..5c9deefd10 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -132,6 +132,7 @@ export interface WorkspaceDescriptor { projectCustomIconRevision?: string | null; projectRootPath: string; workspaceDirectory: string; + worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"]; projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; name: string; @@ -167,6 +168,7 @@ export function normalizeWorkspaceDescriptor( // consumer can read workspace.workspaceDirectory directly. Empty means "no // usable directory" (older daemons may omit it; the wire field is optional). workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "", + worktreeSlug: payload.worktreeSlug, projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, name: payload.name, diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts index 6101963f73..fdd917355c 100644 --- a/packages/app/src/utils/sidebar-project-row-model.test.ts +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -18,6 +18,7 @@ function workspace(overrides: Partial = {}): SidebarWorks projectViewKey: "project-1", projectName: "paseo", workspaceDirectory: "/repo", + workspaceDirectoryLabel: "/repo", projectKind: "git", workspaceKind: "checkout", name: "paseo", diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 196705c2b3..6b008e3dc6 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -27,6 +27,7 @@ function workspace(input: { projectViewKey: input.projectKey ?? "project-default", projectName: input.projectKey ?? "Project", workspaceDirectory: input.workspaceDirectory, + workspaceDirectoryLabel: input.workspaceDirectory, projectKind: "git", workspaceKind: "checkout", name: input.name, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 24ee12e565..378cd55762 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3155,6 +3155,9 @@ export const WorkspaceDescriptorPayloadSchema = z projectCustomIconRevision: z.string().nullable().optional(), projectRootPath: z.string(), workspaceDirectory: z.string().optional(), + // COMPAT(worktreeSlug): added in v0.2.6, remove optional after 2027-01-31. + // Present only for Paseo-owned worktrees; this is the basename of their root directory. + worktreeSlug: z.string().optional(), projectKind: z.enum(["git", "non_git", "directory"]), // COMPAT(workspaces): keep legacy directory workspace kind parseable. workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 72146149fe..03a0bd4321 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -499,6 +499,26 @@ describe("workspace message schemas", () => { throw new Error("Expected workspace_update upsert payload"); } expect(parsed.payload.workspace.workspaceDirectory).toBe("/repo"); + expect(parsed.payload.workspace.worktreeSlug).toBeUndefined(); + }); + + test("preserves a Paseo-owned worktree slug", () => { + const parsed = WorkspaceDescriptorPayloadSchema.parse({ + id: "owned-worktree", + projectId: "project", + projectDisplayName: "repo", + projectRootPath: "/repo", + workspaceDirectory: "/paseo/worktrees/project/feature/packages/app", + worktreeSlug: "feature", + projectKind: "git", + workspaceKind: "worktree", + name: "feature", + status: "done", + activityAt: null, + scripts: [], + }); + + expect(parsed.worktreeSlug).toBe("feature"); }); test("defaults omitted workspace archiving state and preserves present timestamps", () => { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 24c2c9861d..a73bb66161 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,7 +1,7 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; -import { resolve, sep } from "path"; +import { basename, resolve, sep } from "path"; import { homedir } from "node:os"; import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import { @@ -4276,6 +4276,11 @@ export class Session { diffStat = snapshot.git.diffStat; } + const worktreeSlug = + workspace.isPaseoOwnedWorktree && workspace.worktreeRoot + ? basename(workspace.worktreeRoot) + : undefined; + return { id: workspace.workspaceId, projectId: workspace.projectId, @@ -4286,6 +4291,7 @@ export class Session { projectCustomIconRevision: resolvedProjectRecord?.customIconRevision ?? null, projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, workspaceDirectory: workspace.cwd, + worktreeSlug, projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", workspaceKind: workspace.kind, name: resolveWorkspaceDisplayName(workspace), @@ -4373,6 +4379,7 @@ export class Session { projectCustomIconRevision: projectRecord?.customIconRevision ?? null, projectRootPath: projectRecord?.rootPath ?? result.repoRoot, workspaceDirectory: result.workspace.cwd, + worktreeSlug: basename(result.worktree.worktreePath), projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, name: resolveWorkspaceName({ From a54cb66bf621aa6c198a9f9f86be7cd65ce643ce Mon Sep 17 00:00:00 2001 From: Shintaro Sakata <56592600+shin-sakata@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:29:02 +0900 Subject: [PATCH 43/46] fix(nix): keep runtime mode out of agent environment (#2697) --- nix/module.nix | 1 - nix/package.nix | 3 ++- .../26-daemon-launch-supervision.test.ts | 21 ++++++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/nix/module.nix b/nix/module.nix index 72ab1bf812..ae7f4a91ef 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -230,7 +230,6 @@ in ''; environment = { - NODE_ENV = "production"; PASEO_HOME = cfg.dataDir; PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}"; } // lib.optionalAttrs cfg.inheritUserEnvironment ( diff --git a/nix/package.nix b/nix/package.nix index 4b4ca73f1e..b30665b8c9 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -114,9 +114,10 @@ buildNpmPackage rec { # Create wrapper for the server entry point (for systemd / direct use) mkdir -p $out/bin + # Keep Paseo's runtime mode separate from NODE_ENV, which belongs to spawned agents. makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \ --add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \ - --set NODE_ENV production + --set PASEO_NODE_ENV production # Create wrapper for the CLI makeWrapper ${nodejs}/bin/node $out/bin/paseo \ diff --git a/packages/cli/tests/26-daemon-launch-supervision.test.ts b/packages/cli/tests/26-daemon-launch-supervision.test.ts index e0e00bd002..bf9f2fa164 100644 --- a/packages/cli/tests/26-daemon-launch-supervision.test.ts +++ b/packages/cli/tests/26-daemon-launch-supervision.test.ts @@ -18,6 +18,7 @@ const serverConnectionOfferE2ePath = join( ); const desktopRuntimePathsPath = join(repoRoot, "packages/desktop/src/daemon/runtime-paths.ts"); const nixPackagePath = join(repoRoot, "nix/package.nix"); +const nixModulePath = join(repoRoot, "nix/module.nix"); function assertNoDirectWorkerLaunch(label: string, command: string): void { assert( @@ -100,6 +101,24 @@ assert( "Nix paseo-server wrapper should use dist/scripts/supervisor-entrypoint.js", ); assertNoDirectWorkerLaunch("Nix package wrapper", nixPackage); -console.log("✓ desktop runtime and Nix wrapper enter supervisor\n"); +assert( + nixPackage.includes("--set PASEO_NODE_ENV production"), + "Nix paseo-server wrapper should keep the daemon runtime mode Paseo-scoped", +); +assert( + !/--set(-default)?\s+NODE_ENV\b/.test(nixPackage), + "Nix paseo-server wrapper must not leak NODE_ENV to external agent processes", +); + +const nixModule = await readFile(nixModulePath, "utf-8"); +assert( + !/\bNODE_ENV\b\s*=/.test(nixModule), + "NixOS module must not leak NODE_ENV to external agent processes", +); +assert( + !/\bPASEO_NODE_ENV\b/.test(nixModule), + "NixOS module must not duplicate the packaged daemon runtime mode", +); +console.log("✓ desktop runtime and Nix wrapper enter supervisor without leaking runtime env\n"); console.log("=== Daemon launch supervision regression test passed ==="); From c0dcb4433a7d21202db5c5856170f3b4399ba1e2 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 31 Jul 2026 15:03:50 -0700 Subject: [PATCH 44/46] fix(nix): keep docs and CI out of derivation sources (#2652) Exclude non-build repository content from the daemon and desktop derivation inputs while preserving nested runtime Markdown such as skill definitions. --- nix/desktop-package.nix | 15 +++++++++++++++ nix/package.nix | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/nix/desktop-package.nix b/nix/desktop-package.nix index 9703c497ae..cd9c6ca5c2 100644 --- a/nix/desktop-package.nix +++ b/nix/desktop-package.nix @@ -33,6 +33,21 @@ buildNpmPackage rec { && !(lib.hasPrefix "/packages/app/ios" relPath) # Website is unrelated to the desktop app && !(lib.hasPrefix "/packages/website" relPath) + # Documentation, CI definitions and agent/editor configuration. None of + # these reach the build, but every one of them is part of `src`, so a + # docs-only or workflow-only commit currently invalidates the whole + # desktop derivation and pays for a full Expo export to produce a + # byte-identical result. + && !(lib.hasPrefix "/docs" relPath) + && !(lib.hasPrefix "/.github" relPath) + && !(lib.hasPrefix "/.agents" relPath) + && !(lib.hasPrefix "/.claude" relPath) + && !(lib.hasPrefix "/.codex" relPath) + && !(lib.hasPrefix "/docker" relPath) + # Top-level prose only (README, CHANGELOG, AGENTS...). Deeper markdown is + # not necessarily documentation: skills/*/SKILL.md is a runtime file the + # installPhase copies into the output. + && builtins.match "/[^/]+\\.md" relPath == null # Test fixtures and build artifacts && !(lib.hasSuffix ".test.ts" baseName) && !(lib.hasSuffix ".e2e.test.ts" baseName) diff --git a/nix/package.nix b/nix/package.nix index b30665b8c9..417f3d2ad1 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -37,6 +37,21 @@ buildNpmPackage rec { && !(lib.hasPrefix "/packages/website/public" relPath) && !(lib.hasPrefix "/packages/desktop/src" relPath) && !(lib.hasPrefix "/packages/desktop/src-tauri" relPath) + # Documentation, CI definitions and agent/editor configuration. None of + # these reach the build. Excluding them here also matters for the desktop + # derivation, which inherits this package's npmDeps: leaving them in makes + # a docs-only commit produce a new npm-deps .drv, and so a new desktop + # .drv, and so a full rebuild for a byte-identical result. + && !(lib.hasPrefix "/docs" relPath) + && !(lib.hasPrefix "/.github" relPath) + && !(lib.hasPrefix "/.agents" relPath) + && !(lib.hasPrefix "/.claude" relPath) + && !(lib.hasPrefix "/.codex" relPath) + && !(lib.hasPrefix "/docker" relPath) + # Top-level prose only (README, CHANGELOG, AGENTS...). Deeper markdown is + # not necessarily documentation: skills/*/SKILL.md is a runtime file the + # daemon's trace script copies into the output. + && builtins.match "/[^/]+\\.md" relPath == null # Exclude test fixtures and debug files && !(lib.hasSuffix ".test.ts" baseName) && !(lib.hasSuffix ".e2e.test.ts" baseName) From 5f575fcdd60a032f0265e3f9bb67b3708092cfbf Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 31 Jul 2026 15:14:39 -0700 Subject: [PATCH 45/46] fix(dev): use portable Bash shebangs (#2536) Resolve Bash through PATH so repository development entrypoints run on NixOS and other systems without /bin/bash. --- docs/coding-standards.md | 4 ++++ packages/desktop/scripts/dev.sh | 2 +- scripts/dev-app.sh | 2 +- scripts/dev-daemon.sh | 2 +- scripts/dev-home.sh | 2 +- scripts/dev.sh | 2 +- scripts/paseo-ios-simulator-service.sh | 2 +- 7 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 31fb96bc48..ec77d103b8 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -14,6 +14,10 @@ For testing rules, see [testing.md](testing.md). - **`interface`** over `type` when both work. - **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source. +## Shell scripts + +- Bash scripts always use `#!/usr/bin/env bash`. Never hard-code `/bin/bash` or `/usr/bin/bash`; those paths are not portable to environments such as NixOS. + ## Comments and noise - Delete any comment where removing it loses zero information. Comments explain _why_, not _what_. diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index 01f69236c7..bcf233bf48 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/dev-app.sh b/scripts/dev-app.sh index 1f4babf5a4..be9857af65 100755 --- a/scripts/dev-app.sh +++ b/scripts/dev-app.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/dev-daemon.sh b/scripts/dev-daemon.sh index b02a16ca1b..dda30f7a4b 100755 --- a/scripts/dev-daemon.sh +++ b/scripts/dev-daemon.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/dev-home.sh b/scripts/dev-home.sh index 98e5a00c6e..a7cea3e051 100755 --- a/scripts/dev-home.sh +++ b/scripts/dev-home.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash default_dev_paseo_root() { git rev-parse --show-toplevel 2>/dev/null || pwd diff --git a/scripts/dev.sh b/scripts/dev.sh index f589a262cd..9e3eb1aea7 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -e exec npm run dev:server diff --git a/scripts/paseo-ios-simulator-service.sh b/scripts/paseo-ios-simulator-service.sh index 517ba2c07c..b908c1de73 100755 --- a/scripts/paseo-ios-simulator-service.sh +++ b/scripts/paseo-ios-simulator-service.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" From b560c1184cdb39db3215298c6776fcf89b3c14e7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 1 Aug 2026 00:35:27 +0200 Subject: [PATCH 46/46] test(server): wait for terminal env marker contents (#2715) The Windows runner could observe the marker after creation but before its contents were written. Wait for the expected value so the test synchronizes on the behavior it asserts. --- packages/server/src/terminal/terminal-manager.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index f08b1a4ffd..47905d1424 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -202,7 +202,10 @@ it("inherits registered env for the worktree root cwd", async () => { ], }); - await waitForCondition(() => existsSync(markerPath), 10000); + await waitForCondition( + () => existsSync(markerPath) && readFileSync(markerPath, "utf8") === "45678", + 10000, + ); expect(readFileSync(markerPath, "utf8")).toBe("45678"); }); @@ -228,7 +231,10 @@ it("inherits registered env for subdirectories within the worktree", async () => ], }); - await waitForCondition(() => existsSync(markerPath), 10000); + await waitForCondition( + () => existsSync(markerPath) && readFileSync(markerPath, "utf8") === "45679", + 10000, + ); expect(readFileSync(markerPath, "utf8")).toBe("45679"); });