From 7d76da324953bfcb6e378b60ec0484dc059f428b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 7 Mar 2026 19:09:00 +0700 Subject: [PATCH] Update workspace and app changes --- ...AN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md | 160 ----- ...ERATION_3_WORKSPACE_HEADER_TABS_RESTORE.md | 60 -- ...ATION_4_WORKSPACE_HEADER_LAYOUT_RESTORE.md | 113 ---- EXECUTION_PLAN_PASEO.md | 138 ----- packages/app/src/app/_layout.tsx | 1 + packages/app/src/app/h/[serverId]/index.tsx | 33 +- .../app/src/app/h/[serverId]/open-project.tsx | 9 + packages/app/src/components/left-sidebar.tsx | 28 +- .../src/components/sidebar-workspace-list.tsx | 578 ++++++++++++++---- packages/app/src/contexts/session-context.tsx | 22 +- packages/app/src/hooks/use-command-center.ts | 8 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 8 +- .../hooks/use-sidebar-workspaces-list.test.ts | 70 ++- .../src/hooks/use-sidebar-workspaces-list.ts | 24 +- .../app/src/keyboard/keyboard-shortcuts.ts | 2 +- .../src/screens/agent/draft-agent-screen.tsx | 10 +- .../app/src/screens/open-project-screen.tsx | 206 +++++++ .../screens/workspace/workspace-screen.tsx | 72 ++- .../workspace-source-of-truth.test.ts | 4 + packages/app/src/stores/session-store.ts | 23 + .../runtime/terminal-emulator-runtime.test.ts | 38 ++ .../runtime/terminal-emulator-runtime.ts | 32 + packages/app/src/utils/host-routes.test.ts | 6 + packages/app/src/utils/host-routes.ts | 41 +- .../utils/sidebar-project-row-model.test.ts | 103 ++++ .../src/utils/sidebar-project-row-model.ts | 47 ++ .../app/src/utils/sidebar-shortcuts.test.ts | 2 + packages/server/src/client/daemon-client.ts | 28 + ...agent.interrupt-restart-regression.test.ts | 171 ++++++ packages/server/src/server/bootstrap.ts | 24 +- packages/server/src/server/session.ts | 481 +++++++-------- .../src/server/session.workspaces.test.ts | 207 ++++++- .../server/src/server/websocket-server.ts | 35 +- .../workspace-registry-bootstrap.test.ts | 167 +++++ .../server/workspace-registry-bootstrap.ts | 142 +++++ .../src/server/workspace-registry-model.ts | 192 ++++++ .../src/server/workspace-registry.test.ts | 106 ++++ .../server/src/server/workspace-registry.ts | 221 +++++++ packages/server/src/shared/messages.ts | 43 ++ .../src/shared/messages.workspaces.test.ts | 14 + 40 files changed, 2693 insertions(+), 976 deletions(-) delete mode 100644 EXECUTION_PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md delete mode 100644 EXECUTION_PLAN_ITERATION_3_WORKSPACE_HEADER_TABS_RESTORE.md delete mode 100644 EXECUTION_PLAN_ITERATION_4_WORKSPACE_HEADER_LAYOUT_RESTORE.md delete mode 100644 EXECUTION_PLAN_PASEO.md create mode 100644 packages/app/src/app/h/[serverId]/open-project.tsx create mode 100644 packages/app/src/screens/open-project-screen.tsx create mode 100644 packages/app/src/utils/sidebar-project-row-model.test.ts create mode 100644 packages/app/src/utils/sidebar-project-row-model.ts create mode 100644 packages/server/src/server/workspace-registry-bootstrap.test.ts create mode 100644 packages/server/src/server/workspace-registry-bootstrap.ts create mode 100644 packages/server/src/server/workspace-registry-model.ts create mode 100644 packages/server/src/server/workspace-registry.test.ts create mode 100644 packages/server/src/server/workspace-registry.ts diff --git a/EXECUTION_PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md b/EXECUTION_PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md deleted file mode 100644 index 68783d49c..000000000 --- a/EXECUTION_PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md +++ /dev/null @@ -1,160 +0,0 @@ -# Execution Plan — Iteration 2 Projects → Workspaces → Tabs (Paseo Orchestrated) - -This document describes **how** we will execute the fixes defined in: -- `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md` - -It is optimized for parallel work using **Paseo-managed worktrees** and strict quality gates. - ---- - -## Constraints / Guardrails - -- **Do not restart or modify** the user’s main daemon on `localhost:6767`. -- Use **isolated dev stacks** for manual verification (new daemon + new Metro) via: - - `PASEO_HOME=~/.paseo-` and `npm run dev` (auto-picks free ports). -- No “legacy view” preserved: we fix the current UX directly (no dead/unused code paths left behind). -- Agents must treat **terminals and agents as equal first-class tab types** (no special layouts). -- Keep changes focused to the reported issues; avoid unrelated refactors. - ---- - -## Work Breakdown (Parallel) - -### Agent A — Sidebar drag scoping + sidebar polish - -**Worktree:** `polish/sidebar-dnd-and-style` - -Responsibilities: -- Fix project drag so dragging a **project header** reorders the **entire project section** (header + workspaces). -- Fix workspace drag so workspaces reorder **only within their project** (no cross-project placement). -- Ensure sidebar list **snaps back** to canonical `Project → Workspaces` structure after any drag. -- Sidebar visuals: - - remove workspace “border” style, match project “ghost” style language - - remove “No agents yet” - - reduce workspace indentation/padding (mobile-friendly) -- Navigation polish: - - clicking a workspace closes the left sidebar (mobile) - -### Agent B — Workspace header + tabs polish (icons, unified create, persistence) - -**Worktree:** `polish/workspace-tabs-and-header` - -Responsibilities: -- Workspace header shows **branch name** for git workspaces (including base branch like `main`). -- Replace separate “create agent” vs “create terminal” rows with **one unified New Tab control** (agent + terminal). -- Agent tabs show **provider icons** (Claude + Codex minimum, using existing assets/components). -- Fix “remember focused tab per workspace” so: - - switching away and back restores the last focused agent/terminal tab - - stored selection is **not overwritten** while agent/terminal lists are still loading - -### Agent C — Review / sanity check (no code changes) - -Runs after merges to: -- review diff for edge cases + regressions -- double-check acceptance criteria mapping -- call out missing verification steps - ---- - -## Agent Launch Commands (local CLI) - -We use the repo-local CLI: - -```bash -npm run -s cli -- run --provider codex --model gpt-5.3-codex --mode full-access --worktree --name "" --detach "<prompt>" --quiet -``` - -Notes: -- `--detach --quiet` returns the agent ID quickly so we can launch in parallel. -- Each agent must **commit** their work in their worktree branch before finishing. - ---- - -## Prompts (exact) - -### Prompt for Agent A - -Title: `🎭 Sidebar DnD + Polish` - -Prompt: -- Implement **only** the items in “Sidebar drag behavior” + “Sidebar visuals + navigation polish” from `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`. -- Do not change gestures beyond the required drag constraints. -- Ensure the post-drag list snaps back to the canonical project/workspace grouping. -- Remove “No agents yet” and fix workspace row styling/indentation. -- Close sidebar on workspace selection (mobile). -- Run `npm run typecheck` and `npm run test --workspace=@getpaseo/app` in the worktree. -- Commit with a clear message. - -### Prompt for Agent B - -Title: `🎭 Workspace Tabs + Header` - -Prompt: -- Implement **only** the items in “Workspace header + tab bar fixes” from `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`. -- Terminals and agents must be treated as identical first-class tab types (no separate rows/layout). -- Add provider icons for agent tabs (Claude/Codex minimum) using existing app icon components. -- Fix per-workspace focused-tab persistence (don’t overwrite selection while queries are pending). -- Run `npm run typecheck` and `npm run test --workspace=@getpaseo/app` in the worktree. -- Commit with a clear message. - -### Prompt for Agent C (review-only) - -Title: `🎭 Review: Sidebar + Tabs Polish` - -Prompt: -- Review the combined diff for correctness vs acceptance criteria in `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md`. -- DO NOT edit code. Provide a checklist of anything missing or risky. - ---- - -## Merge Strategy (back to `main`) - -1. Wait for Agents A + B to complete. -2. For each worktree branch: - - verify it has a clean commit history (no unrelated changes) - - re-run `npm run typecheck` if needed -3. Merge into `main` sequentially: - - merge A - - rebase/merge B on top of updated `main` (resolve conflicts if any) -4. Do not delete/prune worktrees until the user has manually verified. - ---- - -## Verification Gates (strict) - -### 1) Automated (must pass) - -From repo root on `main` after merges: - -```bash -npm run typecheck -npm run test --workspace=@getpaseo/app -``` - -Optional (run if environment supports it; starts isolated daemon/metro itself): - -```bash -npm run test:e2e --workspace=@getpaseo/app -``` - -### 2) Manual (must be performed by us before handing back) - -Use an **isolated dev stack** (new daemon + new Metro): - -```bash -PASEO_HOME=~/.paseo-iter2-polish npm run dev -``` - -Then use `agent-browser` to verify the “Manual (agent-browser)” section in: -- `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md` - ---- - -## Completion Definition - -We are “done” when: -- All acceptance criteria in `PLAN_ITERATION_2_PROJECTS_WORKSPACES_TABS.md` are met. -- Automated verification gates pass. -- Manual verification steps pass. -- Changes are merged into `main` with no leftover legacy code paths. - diff --git a/EXECUTION_PLAN_ITERATION_3_WORKSPACE_HEADER_TABS_RESTORE.md b/EXECUTION_PLAN_ITERATION_3_WORKSPACE_HEADER_TABS_RESTORE.md deleted file mode 100644 index fa29cfc3d..000000000 --- a/EXECUTION_PLAN_ITERATION_3_WORKSPACE_HEADER_TABS_RESTORE.md +++ /dev/null @@ -1,60 +0,0 @@ -# Iteration 3 Execution Plan — Paseo Orchestrated - -## Principles - -- Work happens in an isolated git worktree and merges back to `main` once verified. -- Do not restart or touch the daemon on `localhost:6767`. -- Use the repo’s existing Playwright global setup (isolated daemon/metro) for E2E. -- Pass all gates before merging: - - Typecheck - - Vitest - - Playwright E2E - - Manual `agent-browser` verification - -## Agent Delegation (Paseo) - -### Implementation agent (1) - -- **Agent:** Codex -- **Mode:** full-access -- **Worktree:** `iter3-workspace-header-tabs-restore` -- **Mission:** - - Fix New tab dropdown visibility (on-screen, correct pattern) - - Restore workspace header structure + explorer toggle + agent kebab menu - - Add terminal close (X) from workspace tab strip - - Add/adjust Playwright E2E specs for the above - - Run verification commands before declaring done - -### Reviewer (optional, if needed) - -- Only used if implementation is large/risky or tests expose subtle regressions. -- Codex or Claude Sonnet as a second-pass reviewer for UI regressions. - -## Merge Strategy - -1. Agent commits all changes in the worktree. -2. Orchestrator reviews the diff on the worktree. -3. Run gates locally on the worktree: - - `npm run typecheck` - - `npm run test --workspace=@getpaseo/app` - - `npm run test:e2e --workspace=@getpaseo/app` -4. Manual `agent-browser` verification: - - Desktop viewport: New tab menu visible + explorer toggle + kebab menu - - Mobile viewport: explorer icon uses git/folder, toggles right sidebar, left sidebar unaffected -5. Merge worktree back into `main` with a fast-forward merge if possible; otherwise merge commit. - -## Verification Checklist (strict) - -- [ ] New tab menu opens and is visible (desktop) -- [ ] Selecting Agent tab routes to draft agent flow scoped to workspace -- [ ] Selecting Terminal tab creates terminal and focuses it -- [ ] Terminal tabs show X; closing kills terminal and removes tab -- [ ] Workspace header shows branch name -- [ ] Workspace header has explorer toggle with correct icon behavior -- [ ] Agent kebab menu exists when Agent tab active -- [ ] Right sidebar opens/closes via header and mobile swipe gesture -- [ ] Left sidebar gestures unchanged -- [ ] `npm run typecheck` ✅ -- [ ] `npm run test --workspace=@getpaseo/app` ✅ -- [ ] `npm run test:e2e --workspace=@getpaseo/app` ✅ - diff --git a/EXECUTION_PLAN_ITERATION_4_WORKSPACE_HEADER_LAYOUT_RESTORE.md b/EXECUTION_PLAN_ITERATION_4_WORKSPACE_HEADER_LAYOUT_RESTORE.md deleted file mode 100644 index b6b9aafc0..000000000 --- a/EXECUTION_PLAN_ITERATION_4_WORKSPACE_HEADER_LAYOUT_RESTORE.md +++ /dev/null @@ -1,113 +0,0 @@ -# Execution Plan — Iteration 4 (Paseo-orchestrated) - -## Strategy - -Use a Paseo-managed implementation agent in an isolated git worktree to patch the workspace screen to restore legacy header/layout parity while keeping tabs. Then review, validate with tests + agent-browser, and merge back to `main`. - -## Agents - -### 1) Implementation agent (Codex) - -- Provider/model: `codex / gpt-5.3-codex` -- Mode: `full-access` -- Worktree: `iter4-workspace-header-layout-restore` (base: `main`) -- Responsibilities: - - Fix `New tab` dropdown to use the established dropdown/menu pattern (not off-screen combobox). - - Restore workspace header explorer toggle parity with legacy `AgentReadyScreen` (icons, aria state). - - Restore agent overflow (kebab) menu when active tab is an agent. - - Add terminal tab close `X` on desktop with confirm + kill terminal mutation. - - Add/extend Playwright E2E specs for `New tab` on-screen + explorer toggle open/close. - - Commit changes. - -### 2) Reviewer/validator (you/me) - -- Review diffs locally. -- Run: - - `npm run typecheck` - - `npm run test --workspace=@getpaseo/app` - - Targeted Playwright spec(s) for this iteration -- Perform agent-browser manual verification (desktop + mobile viewports). - -## Tooling / Commands (canonical) - -### Create agent (detached) - -```bash -paseo run -d \ - --worktree iter4-workspace-header-layout-restore \ - --base main \ - --provider codex \ - --model gpt-5.3-codex \ - --mode full-access \ - --name "🎭 Iter4 workspace header/layout restore" \ - "<paste the implementation prompt>" -``` - -### Wait - -```bash -paseo wait <agent-id> -``` - -### Review worktree diff - -```bash -cd ~/.paseo/worktrees/<hash>/iter4-workspace-header-layout-restore -git status --short --branch -git log -n 5 --oneline -git diff main..HEAD -``` - -### Merge into main - -Prefer `git cherry-pick <commit>` into `/Users/moboudra/dev/paseo` `main` after verification. - -## Verification gates (must be green) - -### 1) Typecheck - -```bash -npm run typecheck -``` - -### 2) App unit tests - -```bash -npm run test --workspace=@getpaseo/app -``` - -### 3) Playwright E2E (targeted) - -Run only the spec(s) for this iteration (avoid unrelated flakes): - -```bash -cd packages/app -npx playwright test e2e/workspace-header-tabs-restore.spec.ts -``` - -## Manual verification (agent-browser) - -Use explicit sessions: - -```bash -agent-browser --session iter4-desktop open http://localhost:8081 -agent-browser --session iter4-mobile open http://localhost:8081 -``` - -Desktop: -- Validate `New tab` dropdown opens on-screen -- Validate explorer toggle opens/closes explorer -- Validate kebab menu appears for agent tabs -- Validate terminal tab `X` close flow - -Mobile viewport: -- Validate git/folder icon for explorer toggle -- Validate gestures for left/right sidebars - -## Rollback plan - -If verification fails: -- Do not merge. -- Patch in worktree until acceptance criteria + gates pass. -- Only then cherry-pick/merge into `main`. - diff --git a/EXECUTION_PLAN_PASEO.md b/EXECUTION_PLAN_PASEO.md deleted file mode 100644 index 19c5a4b2a..000000000 --- a/EXECUTION_PLAN_PASEO.md +++ /dev/null @@ -1,138 +0,0 @@ -# Execution Plan (Paseo Orchestrator) - -This document is the concrete execution plan for `PLAN_PROJECTS_WORKSPACES_TABS.md`, using the Paseo CLI to delegate work to sub-agents running inside isolated git worktrees. - -## Conventions - -- All agent names are prefixed with `🎭` so they’re easy to identify. -- Use **Codex** for implementation work (`--provider codex --mode full-access`). -- Each agent runs in its **own worktree** to avoid concurrent writes to the same git working directory. -- Agents must avoid “legacy/compat mode” code paths: we are fully redirecting/reworking. - -## 0) Preflight - -```bash -git branch --show-current -npm run -s cli -- daemon status -``` - -Expected: -- current branch is `main` (or the branch you want as base) -- daemon is reachable - -## 1) Launch parallel agents (detached) - -### Agent A — Server protocol + workspace data - -Scope: -- Replace file explorer + download token RPC to be **workspace-scoped** (remove `agentId` usage). -- Extend worktree list payload with `createdAt` (and a stable `workspaceId` if needed). -- Ensure agent index loads once at daemon startup (no repeated disk hydration). - -```bash -SERVER_ID=$(npm run -s cli -- run -d -q \ - --name "🎭 PWT Server: workspace RPCs" \ - --provider codex --mode full-access \ - --worktree pwt-server-workspace-rpcs --base main \ - "Implement server-side changes from PLAN_PROJECTS_WORKSPACES_TABS.md: replace file_explorer_request and file_download_token_request to be workspace-scoped (no agentId), update handlers + client types, add createdAt to paseo_worktree_list_response, and make agent storage/index load once at daemon startup. Do NOT add legacy compatibility. Keep changes minimal and typecheck. Output a short checklist of touched files + how to test." -) -echo "$SERVER_ID" -``` - -### Agent B — App: workspace routes + tabs main view - -Scope: -- Add workspace routes/screens and redirect old agent routes. -- Implement workspace header + tab bar (agent + terminal tabs). -- Persist/restore last focused tab per workspace. -- Mobile tab switcher + plus button (draft agent flow pre-scoped). - -```bash -APP_TABS_ID=$(npm run -s cli -- run -d -q \ - --name "🎭 PWT App: workspace tabs" \ - --provider codex --mode full-access \ - --worktree pwt-app-workspace-tabs --base main \ - "Implement app-side workspace main view + tabs from PLAN_PROJECTS_WORKSPACES_TABS.md. Replace old /h/:serverId/agent routes with workspace routes, render a workspace screen with a horizontal tab bar (agent + terminal tabs), restore last focused tab, and implement a mobile tab switcher + header plus button to open draft agent flow pre-scoped to workspace. Terminals are just tabs; no special casing. Do NOT keep legacy UI. Keep gestures/overlay sidebars unchanged. Typecheck." -) -echo \"$APP_TABS_ID\" -``` - -### Agent C — App: left sidebar projects → workspaces - -Scope: -- Replace left sidebar agent list with Project → Workspace tree. -- Project icon, status dot aggregation, reorder persistence. -- Workspace rows: branch + createdAt, no path. - -```bash -APP_SIDEBAR_ID=$(npm run -s cli -- run -d -q \ - --name "🎭 PWT App: projects sidebar" \ - --provider codex --mode full-access \ - --worktree pwt-app-projects-sidebar --base main \ - "Implement the left sidebar rewrite per PLAN_PROJECTS_WORKSPACES_TABS.md: Projects grouped by projectKey (remote when available else local), each project shows icon + status dot, and contains workspaces (main checkout + Paseo worktrees incl empty). Workspace row shows branch name + createdAt only (no path). Keep drag reorder for projects + workspaces and persist on-device. Do not show agents in sidebar. Typecheck." -) -echo \"$APP_SIDEBAR_ID\" -``` - -### Agent D — App: right sidebar changes + files (workspace-scoped) - -Scope: -- Remove terminals from the right sidebar. -- Make Changes/Files sidebar workspace-scoped (not agent-scoped). -- Update file explorer calls to use new workspace-scoped RPC. - -```bash -APP_EXPLORER_ID=$(npm run -s cli -- run -d -q \ - --name "🎭 PWT App: explorer sidebar" \ - --provider codex --mode full-access \ - --worktree pwt-app-explorer-sidebar --base main \ - "Update the right sidebar per PLAN_PROJECTS_WORKSPACES_TABS.md: it must contain only Changes + Files and be scoped to the opened workspace (not the selected tab). Remove terminals from the right sidebar entirely. Replace file explorer client actions/state to use the new workspace-scoped RPC (no agentId). Ensure empty workspaces still work. Typecheck." -) -echo \"$APP_EXPLORER_ID\" -``` - -## 2) Wait for completion - -```bash -npm run -s cli -- wait "$SERVER_ID" -npm run -s cli -- wait "$APP_TABS_ID" -npm run -s cli -- wait "$APP_SIDEBAR_ID" -npm run -s cli -- wait "$APP_EXPLORER_ID" -``` - -## 3) Collect diffs from each worktree - -```bash -SERVER_CWD=$(npm run -s cli -- inspect "$SERVER_ID" --json | jq -r '.cwd') -APP_TABS_CWD=$(npm run -s cli -- inspect "$APP_TABS_ID" --json | jq -r '.cwd') -APP_SIDEBAR_CWD=$(npm run -s cli -- inspect "$APP_SIDEBAR_ID" --json | jq -r '.cwd') -APP_EXPLORER_CWD=$(npm run -s cli -- inspect "$APP_EXPLORER_ID" --json | jq -r '.cwd') - -git -C "$SERVER_CWD" diff > /tmp/pwt-server.patch -git -C "$APP_TABS_CWD" diff > /tmp/pwt-app-tabs.patch -git -C "$APP_SIDEBAR_CWD" diff > /tmp/pwt-app-sidebar.patch -git -C "$APP_EXPLORER_CWD" diff > /tmp/pwt-app-explorer.patch -``` - -## 4) Integrate (apply patches in order) - -Recommended: create a clean integration worktree/branch first, then apply patches. - -```bash -# In a clean integration branch/worktree: -git apply /tmp/pwt-server.patch -git apply /tmp/pwt-app-tabs.patch -git apply /tmp/pwt-app-sidebar.patch -git apply /tmp/pwt-app-explorer.patch - -npm run typecheck -``` - -If `git apply` fails due to overlap, apply one patch at a time and resolve manually, then re-run typecheck. - -## 5) QA pass - -- Verify acceptance criteria list in `PLAN_PROJECTS_WORKSPACES_TABS.md`. -- Smoke test navigation: open workspace → tabs → right sidebar → plus flow. -- Confirm no remaining agentId-based explorer/download usage. - diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 04af26c5a..e1bbb9fba 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -484,6 +484,7 @@ export default function RootLayout() { <Stack.Screen name="h/[serverId]/index" /> <Stack.Screen name="h/[serverId]/agents" /> <Stack.Screen name="h/[serverId]/new-agent" /> + <Stack.Screen name="h/[serverId]/open-project" /> <Stack.Screen name="h/[serverId]/settings" /> <Stack.Screen name="pair-scan" /> </Stack> diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index ddbca8018..ae00cb487 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -3,6 +3,7 @@ import { useLocalSearchParams, usePathname, useRouter } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { + buildHostOpenProjectRoute, buildHostRootRoute, buildHostWorkspaceAgentRoute, buildHostWorkspaceRoute, @@ -15,10 +16,13 @@ export default function HostIndexRoute() { const pathname = usePathname(); const params = useLocalSearchParams<{ serverId?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; - const { preferences, isLoading: preferencesLoading } = useFormPreferences(); + const { isLoading: preferencesLoading } = useFormPreferences(); const sessionAgents = useSessionStore( (state) => (serverId ? state.sessions[serverId]?.agents : undefined) ); + const sessionWorkspaces = useSessionStore( + (state) => (serverId ? state.sessions[serverId]?.workspaces : undefined) + ); useEffect(() => { if (preferencesLoading) { @@ -37,14 +41,21 @@ export default function HostIndexRoute() { } const visibleAgents = sessionAgents - ? Array.from(sessionAgents.values()).filter( - (agent) => !agent.archivedAt - ) + ? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt) : []; visibleAgents.sort( (left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime() ); + const visibleWorkspaces = sessionWorkspaces + ? Array.from(sessionWorkspaces.values()) + : []; + visibleWorkspaces.sort((left, right) => { + const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY; + const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY; + return rightTime - leftTime; + }); + const primaryAgent = visibleAgents[0]; if (primaryAgent?.cwd?.trim()) { router.replace( @@ -57,21 +68,23 @@ export default function HostIndexRoute() { return; } - const preferredWorkingDir = - preferences.serverId === serverId ? preferences.workingDir?.trim() : ""; - const workspaceId = preferredWorkingDir || "."; - router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any); + const primaryWorkspace = visibleWorkspaces[0]; + if (primaryWorkspace?.id?.trim()) { + router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any); + return; + } + + router.replace(buildHostOpenProjectRoute(serverId) as any); }, HOST_ROOT_REDIRECT_DELAY_MS); return () => clearTimeout(timer); }, [ pathname, - preferences.serverId, - preferences.workingDir, preferencesLoading, router, serverId, sessionAgents, + sessionWorkspaces, ]); return null; diff --git a/packages/app/src/app/h/[serverId]/open-project.tsx b/packages/app/src/app/h/[serverId]/open-project.tsx new file mode 100644 index 000000000..829a2a623 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/open-project.tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from "expo-router"; +import { OpenProjectScreen } from "@/screens/open-project-screen"; + +export default function HostOpenProjectRoute() { + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId : ""; + + return <OpenProjectScreen serverId={serverId} />; +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 91752053c..20a3ab7a1 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -25,7 +25,7 @@ import { formatConnectionStatus } from '@/utils/daemons' import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout' import { buildHostAgentsRoute, - buildHostNewAgentRoute, + buildHostOpenProjectRoute, buildHostSettingsRoute, mapPathnameToServer, parseServerIdFromPathname, @@ -158,23 +158,21 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr closeToAgent() }, [closeToAgent]) - const handleCreateAgentClean = useCallback(() => { + const handleOpenProject = useCallback(() => { if (!activeServerId) { return } - router.push(buildHostNewAgentRoute(activeServerId) as any) + router.push(buildHostOpenProjectRoute(activeServerId) as any) }, [activeServerId]) - // Mobile: close sidebar and navigate - const handleCreateAgentCleanMobile = useCallback(() => { + const handleOpenProjectMobile = useCallback(() => { closeToAgent() - handleCreateAgentClean() - }, [closeToAgent, handleCreateAgentClean]) + handleOpenProject() + }, [closeToAgent, handleOpenProject]) - // Desktop: just navigate, don't close - const handleCreateAgentCleanDesktop = useCallback(() => { - handleCreateAgentClean() - }, [handleCreateAgentClean]) + const handleOpenProjectDesktop = useCallback(() => { + handleOpenProject() + }, [handleOpenProject]) // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { @@ -332,7 +330,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr <Pressable style={styles.newAgentButton} testID="sidebar-new-agent" - onPress={handleCreateAgentCleanMobile} + onPress={handleOpenProjectMobile} > {({ hovered }) => ( <> @@ -346,7 +344,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr hovered && styles.newAgentButtonTextHovered, ]} > - New agent + Open project </Text> </> )} @@ -459,7 +457,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr <Pressable style={styles.newAgentButton} testID="sidebar-new-agent" - onPress={handleCreateAgentCleanDesktop} + onPress={handleOpenProjectDesktop} > {({ hovered }) => ( <> @@ -470,7 +468,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr <Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]} > - New agent + Open project </Text> </> )} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index bb6adad35..06f8404e0 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -23,7 +23,7 @@ import { import { router, usePathname } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { type GestureType } from 'react-native-gesture-handler' -import { ChevronDown, ChevronRight } from 'lucide-react-native' +import { ChevronDown, ChevronRight, Plus } from 'lucide-react-native' import { NestableScrollContainer } from 'react-native-draggable-flatlist' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import type { DraggableListDragHandleProps } from './draggable-list.types' @@ -31,6 +31,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runt import { getIsTauri } from '@/constants/layout' import { projectIconQueryKey } from '@/hooks/use-project-icon-query' import { + buildHostNewAgentRoute, buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname, } from '@/utils/host-routes' @@ -58,8 +59,12 @@ import { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration' import { confirmDialog } from '@/utils/confirm-dialog' import { projectIconPlaceholderLabelFromDisplayName } from '@/utils/project-display-name' import { shouldRenderSyncedStatusLoader } from '@/utils/status-loader' - -const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model' function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -84,10 +89,15 @@ interface ProjectHeaderRowProps { project: SidebarProjectEntry displayName: string iconDataUri: string | null - collapsed: boolean - onToggle: () => void + workspace: SidebarWorkspaceEntry | null + selected?: boolean + chevron: 'expand' | 'collapse' | 'disclosure' + onPress: () => void + onCreateWorktree?: () => void drag: () => void isDragging: boolean + isArchiving?: boolean + menuController: ReturnType<typeof useContextMenu> | null dragHandleProps?: DraggableListDragHandleProps } @@ -127,16 +137,6 @@ function resolveStatusDotColor(input: { : theme.colors.border } -function isPaseoOwnedWorktreePath(path: string): boolean { - const normalizedPath = path.replace(/\\/g, '/') - const markerIndex = normalizedPath.indexOf(PASEO_WORKTREE_PATH_MARKER) - if (markerIndex <= 0) { - return false - } - const nextChar = normalizedPath[markerIndex + PASEO_WORKTREE_PATH_MARKER.length] - return !nextChar || nextChar === '/' -} - function WorkspaceStatusIndicator({ bucket, loading = false, @@ -161,6 +161,82 @@ function WorkspaceStatusIndicator({ ) } +function ProjectLeadingVisual({ + displayName, + iconDataUri, + workspace, + isArchiving = false, +}: { + displayName: string + iconDataUri: string | null + workspace: SidebarWorkspaceEntry | null + isArchiving?: boolean +}) { + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName) + const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() + const activeWorkspace = workspace + const shouldShowWorkspaceStatus = + activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== 'done') + + return ( + <View style={styles.projectLeadingVisualSlot}> + {shouldShowWorkspaceStatus && activeWorkspace ? ( + <WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} /> + ) : iconDataUri ? ( + <Image source={{ uri: iconDataUri }} style={styles.projectIcon} /> + ) : ( + <View style={styles.projectIconFallback}> + <Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text> + </View> + )} + </View> + ) +} + +function ProjectInlineChevron({ + chevron, +}: { + chevron: 'expand' | 'collapse' | 'disclosure' +}) { + if (chevron === 'collapse') { + return <ChevronDown size={14} color="#9ca3af" /> + } + return <ChevronRight size={14} color="#9ca3af" /> +} + +function NewWorktreeButton({ + displayName, + onPress, + testID, +}: { + displayName: string + onPress: () => void + testID: string +}) { + return ( + <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> + <TooltipTrigger + style={({ hovered, pressed }) => [ + styles.projectIconActionButton, + (hovered || pressed) && styles.projectIconActionButtonHovered, + ]} + onPress={(event) => { + event.stopPropagation() + onPress() + }} + accessibilityRole="button" + accessibilityLabel={`Create a new worktree for ${displayName}`} + testID={testID} + > + <Plus size={14} color="#9ca3af" /> + </TooltipTrigger> + <TooltipContent side="bottom" align="end" offset={8}> + <Text style={styles.projectActionTooltipText}>New worktree</Text> + </TooltipContent> + </Tooltip> + ) +} + function useLongPressDragInteraction(input: { drag: () => void menuController: ReturnType<typeof useContextMenu> | null @@ -411,33 +487,90 @@ function ProjectHeaderRow({ project, displayName, iconDataUri, - collapsed, - onToggle, + workspace, + selected = false, + chevron, + onPress, + onCreateWorktree, drag, isDragging, + isArchiving = false, + menuController, dragHandleProps, }: ProjectHeaderRowProps) { const interaction = useLongPressDragInteraction({ drag, - menuController: null, + menuController, debugId: `project:${project.projectKey}`, }) - const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName) - const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() const handlePress = useCallback(() => { if (interaction.didLongPressRef.current) { interaction.didLongPressRef.current = false return } - onToggle() - }, [interaction.didLongPressRef, onToggle]) + onPress() + }, [interaction.didLongPressRef, onPress]) - const trigger = ( + const rowChildren = ( + <> + <View + {...(dragHandleProps?.attributes as any)} + {...(dragHandleProps?.listeners as any)} + ref={dragHandleProps?.setActivatorNodeRef as any} + style={styles.projectRowLeft} + > + <ProjectLeadingVisual + displayName={displayName} + iconDataUri={iconDataUri} + workspace={workspace} + isArchiving={isArchiving} + /> + + <Text style={styles.projectTitle} numberOfLines={1}> + {displayName} + </Text> + + <ProjectInlineChevron chevron={chevron} /> + </View> + {onCreateWorktree ? ( + <NewWorktreeButton + displayName={displayName} + onPress={onCreateWorktree} + testID={`sidebar-project-new-worktree-${project.projectKey}`} + /> + ) : null} + </> + ) + + if (menuController) { + return ( + <ContextMenuTrigger + enabledOnMobile={false} + style={({ pressed, hovered = false }) => [ + styles.projectRow, + isDragging && styles.projectRowDragging, + selected && styles.sidebarRowSelected, + hovered && styles.projectRowHovered, + pressed && styles.projectRowPressed, + ]} + onPressIn={interaction.handlePressIn} + onTouchMove={interaction.handleTouchMove} + onPressOut={interaction.handlePressOut} + onPress={handlePress} + testID={`sidebar-project-row-${project.projectKey}`} + > + {rowChildren} + </ContextMenuTrigger> + ) + } + + return ( <Pressable style={({ pressed, hovered = false }) => [ styles.projectRow, isDragging && styles.projectRowDragging, + selected && styles.sidebarRowSelected, hovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ]} @@ -447,34 +580,9 @@ function ProjectHeaderRow({ onPress={handlePress} testID={`sidebar-project-row-${project.projectKey}`} > - <View - {...(dragHandleProps?.attributes as any)} - {...(dragHandleProps?.listeners as any)} - ref={dragHandleProps?.setActivatorNodeRef as any} - style={styles.projectRowLeft} - > - {iconDataUri ? ( - <Image source={{ uri: iconDataUri }} style={styles.projectIcon} /> - ) : ( - <View style={styles.projectIconFallback}> - <Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text> - </View> - )} - - <Text style={styles.projectTitle} numberOfLines={1}> - {displayName} - </Text> - - {collapsed ? ( - <ChevronRight size={14} color="#9ca3af" /> - ) : ( - <ChevronDown size={14} color="#9ca3af" /> - )} - </View> + {rowChildren} </Pressable> ) - - return trigger } function WorkspaceRowInner({ @@ -539,7 +647,7 @@ function WorkspaceRowInner({ style={({ pressed, hovered = false }) => [ styles.workspaceRow, isDragging && styles.workspaceRowDragging, - selected && styles.workspaceRowSelected, + selected && styles.sidebarRowSelected, hovered && styles.workspaceRowHovered, pressed && styles.workspaceRowPressed, ]} @@ -557,7 +665,7 @@ function WorkspaceRowInner({ style={({ pressed, hovered = false }) => [ styles.workspaceRow, isDragging && styles.workspaceRowDragging, - selected && styles.workspaceRowSelected, + selected && styles.sidebarRowSelected, hovered && styles.workspaceRowHovered, pressed && styles.workspaceRowPressed, ]} @@ -602,6 +710,7 @@ function WorkspaceRowWithMenuContent({ const toast = useToast() const contextMenu = useContextMenu() const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) + const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false) const archiveStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId: workspace.serverId, @@ -609,7 +718,8 @@ function WorkspaceRowWithMenuContent({ actionId: 'archive-worktree', }) ) - const isArchiving = archiveStatus === 'pending' + const isWorktree = workspace.workspaceKind === 'worktree' + const isArchiving = isWorktree ? archiveStatus === 'pending' : isArchivingWorkspace const handleArchiveWorktree = useCallback(() => { if (isArchiving) { @@ -640,6 +750,43 @@ function WorkspaceRowWithMenuContent({ })() }, [archiveWorktree, isArchiving, toast, workspace.name, workspace.serverId, workspace.workspaceId]) + const handleArchiveWorkspace = useCallback(() => { + if (isArchivingWorkspace) { + return + } + + void (async () => { + const confirmed = await confirmDialog({ + title: 'Hide workspace?', + message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, + confirmLabel: 'Hide', + cancelLabel: 'Cancel', + destructive: true, + }) + if (!confirmed) { + return + } + + const client = getHostRuntimeStore().getClient(workspace.serverId) + if (!client) { + toast.error('Host is not connected') + return + } + + setIsArchivingWorkspace(true) + try { + const payload = await client.archiveWorkspace(workspace.workspaceId) + if (payload.error) { + throw new Error(payload.error) + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to hide workspace') + } finally { + setIsArchivingWorkspace(false) + } + })() + }, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId]) + return ( <> <WorkspaceRowInner @@ -662,12 +809,12 @@ function WorkspaceRowWithMenuContent({ > <ContextMenuItem testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`} - status={archiveStatus} - pendingLabel="Archiving..." + status={isWorktree ? archiveStatus : isArchivingWorkspace ? 'pending' : 'idle'} + pendingLabel={isWorktree ? 'Archiving...' : 'Hiding...'} destructive - onSelect={handleArchiveWorktree} + onSelect={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace} > - Archive worktree + {isWorktree ? 'Archive worktree' : 'Hide from sidebar'} </ContextMenuItem> </ContextMenuContent> </> @@ -709,6 +856,122 @@ function WorkspaceRowWithMenu({ ) } +function NonGitProjectRowWithMenuContent({ + project, + displayName, + iconDataUri, + workspace, + selected, + onPress, + drag, + isDragging, + dragHandleProps, +}: { + project: SidebarProjectEntry + displayName: string + iconDataUri: string | null + workspace: SidebarWorkspaceEntry + selected: boolean + onPress: () => void + drag: () => void + isDragging: boolean + dragHandleProps?: DraggableListDragHandleProps +}) { + const toast = useToast() + const contextMenu = useContextMenu() + const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false) + + const handleArchiveWorkspace = useCallback(() => { + if (isArchivingWorkspace) { + return + } + + void (async () => { + const confirmed = await confirmDialog({ + title: 'Hide workspace?', + message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, + confirmLabel: 'Hide', + cancelLabel: 'Cancel', + destructive: true, + }) + if (!confirmed) { + return + } + + const client = getHostRuntimeStore().getClient(workspace.serverId) + if (!client) { + toast.error('Host is not connected') + return + } + + setIsArchivingWorkspace(true) + try { + const payload = await client.archiveWorkspace(workspace.workspaceId) + if (payload.error) { + throw new Error(payload.error) + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to hide workspace') + } finally { + setIsArchivingWorkspace(false) + } + })() + }, [isArchivingWorkspace, toast, workspace.name, workspace.serverId, workspace.workspaceId]) + + return ( + <> + <ProjectHeaderRow + project={project} + displayName={displayName} + iconDataUri={iconDataUri} + workspace={workspace} + selected={selected} + chevron="disclosure" + onPress={onPress} + drag={drag} + isDragging={isDragging} + isArchiving={isArchivingWorkspace} + menuController={contextMenu} + dragHandleProps={dragHandleProps} + /> + <ContextMenuContent + align="start" + width={220} + mobileMode="sheet" + testID={`sidebar-workspace-context-${workspace.workspaceKey}`} + > + <ContextMenuItem + testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`} + status={isArchivingWorkspace ? 'pending' : 'idle'} + pendingLabel="Hiding..." + destructive + onSelect={handleArchiveWorkspace} + > + Hide from sidebar + </ContextMenuItem> + </ContextMenuContent> + </> + ) +} + +function NonGitProjectRowWithMenu(props: { + project: SidebarProjectEntry + displayName: string + iconDataUri: string | null + workspace: SidebarWorkspaceEntry + selected: boolean + onPress: () => void + drag: () => void + isDragging: boolean + dragHandleProps?: DraggableListDragHandleProps +}) { + return ( + <ContextMenu> + <NonGitProjectRowWithMenuContent {...props} /> + </ContextMenu> + ) +} + function WorkspaceRowPlain({ workspace, selected, @@ -763,21 +1026,6 @@ function WorkspaceRow({ isDragging: boolean dragHandleProps?: DraggableListDragHandleProps }) { - if (!isPaseoOwnedWorktreePath(workspace.workspaceId)) { - return ( - <WorkspaceRowPlain - workspace={workspace} - selected={selected} - shortcutNumber={shortcutNumber} - showShortcutBadge={showShortcutBadge} - onPress={onPress} - drag={drag} - isDragging={isDragging} - dragHandleProps={dragHandleProps} - /> - ) - } - return ( <WorkspaceRowWithMenu workspace={workspace} @@ -805,6 +1053,7 @@ function ProjectBlock({ onToggleCollapsed, onWorkspacePress, onWorkspaceReorder, + onCreateWorktree, drag, isDragging, dragHandleProps, @@ -822,18 +1071,26 @@ function ProjectBlock({ onToggleCollapsed: () => void onWorkspacePress?: () => void onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void + onCreateWorktree?: (project: SidebarProjectEntry) => void drag: () => void isDragging: boolean dragHandleProps?: DraggableListDragHandleProps useNestable: boolean }) { - const renderWorkspace = useCallback( - ({ - item, - drag: workspaceDrag, - isActive, - dragHandleProps: workspaceDragHandleProps, - }: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => { + const rowModel = useMemo( + () => + buildSidebarProjectRowModel({ + project, + collapsed, + serverId, + activeWorkspaceSelection, + }), + [activeWorkspaceSelection, collapsed, project, serverId] + ) + const flattenedWorkspace = rowModel.flattenedWorkspace + + const renderWorkspaceRow = useCallback( + (item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps }) => { const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId) const isSelected = Boolean(serverId) && @@ -853,9 +1110,9 @@ function ProjectBlock({ onWorkspacePress?.() router.replace(workspaceRoute as any) }} - drag={workspaceDrag} - isDragging={isActive} - dragHandleProps={workspaceDragHandleProps} + drag={input?.drag ?? (() => {})} + isDragging={input?.isDragging ?? false} + dragHandleProps={input?.dragHandleProps} /> ) }, @@ -868,33 +1125,79 @@ function ProjectBlock({ ] ) + const renderWorkspace = useCallback( + ({ + item, + drag: workspaceDrag, + isActive, + dragHandleProps: workspaceDragHandleProps, + }: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => { + return renderWorkspaceRow(item, { + drag: workspaceDrag, + isDragging: isActive, + dragHandleProps: workspaceDragHandleProps, + }) + }, + [renderWorkspaceRow] + ) + return ( <View style={styles.projectBlock}> - <ProjectHeaderRow - project={project} - displayName={displayName} - iconDataUri={iconDataUri} - collapsed={collapsed} - onToggle={onToggleCollapsed} - drag={drag} - isDragging={isDragging} - dragHandleProps={dragHandleProps} - /> - - {!collapsed ? ( - <DraggableList - testID={`sidebar-workspace-list-${project.projectKey}`} - data={project.workspaces} - keyExtractor={(workspace) => workspace.workspaceKey} - renderItem={renderWorkspace} - onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)} - scrollEnabled={false} - useDragHandle - nestable={useNestable} - simultaneousGestureRef={parentGestureRef} - containerStyle={styles.workspaceListContainer} + {flattenedWorkspace ? ( + <NonGitProjectRowWithMenu + project={project} + displayName={displayName} + iconDataUri={iconDataUri} + workspace={flattenedWorkspace} + selected={rowModel.selected} + onPress={() => { + if (!serverId) { + return + } + onWorkspacePress?.() + router.replace(buildHostWorkspaceRoute(serverId, flattenedWorkspace.workspaceId) as any) + }} + drag={drag} + isDragging={isDragging} + dragHandleProps={dragHandleProps} /> - ) : null} + ) : ( + <> + <ProjectHeaderRow + project={project} + displayName={displayName} + iconDataUri={iconDataUri} + workspace={null} + selected={false} + chevron={rowModel.chevron} + onPress={onToggleCollapsed} + onCreateWorktree={ + rowModel.trailingAction === 'new_worktree' && onCreateWorktree + ? () => onCreateWorktree(project) + : undefined + } + drag={drag} + isDragging={isDragging} + menuController={null} + dragHandleProps={dragHandleProps} + /> + + {!collapsed ? ( + <DraggableList + testID={`sidebar-workspace-list-${project.projectKey}`} + data={project.workspaces} + keyExtractor={(workspace) => workspace.workspaceKey} + renderItem={renderWorkspace} + onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)} + scrollEnabled={false} + useDragHandle + nestable={useNestable} + simultaneousGestureRef={parentGestureRef} + containerStyle={styles.workspaceListContainer} + /> + ) : null} + </> + )} </View> ) } @@ -1118,6 +1421,21 @@ export function SidebarWorkspaceList({ [getWorkspaceOrder, serverId, setWorkspaceOrder] ) + const handleCreateWorktree = useCallback( + (project: SidebarProjectEntry) => { + if (!serverId || project.projectKind !== 'git') { + return + } + router.push( + buildHostNewAgentRoute(serverId, { + workingDir: project.iconWorkingDir, + worktreeMode: 'create', + }) as any + ) + }, + [serverId] + ) + const renderProject = useCallback( ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => { return ( @@ -1134,6 +1452,7 @@ export function SidebarWorkspaceList({ onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)} onWorkspacePress={onWorkspacePress} onWorkspaceReorder={handleWorkspaceReorder} + onCreateWorktree={handleCreateWorktree} drag={drag} isDragging={isActive} dragHandleProps={dragHandleProps} @@ -1144,6 +1463,7 @@ export function SidebarWorkspaceList({ [ activeWorkspaceSelection, collapsedProjectKeys, + handleCreateWorktree, handleWorkspaceReorder, onWorkspacePress, parentGestureRef, @@ -1273,13 +1593,20 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, }, projectIcon: { - width: theme.iconSize.sm, - height: theme.iconSize.sm, + width: '100%', + height: '100%', borderRadius: theme.borderRadius.sm, }, + projectLeadingVisualSlot: { + width: theme.iconSize.md, + height: theme.iconSize.md, + flexShrink: 0, + alignItems: 'center', + justifyContent: 'center', + }, projectIconFallback: { - width: theme.iconSize.sm, - height: theme.iconSize.sm, + width: '100%', + height: '100%', borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, @@ -1296,6 +1623,37 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minWidth: 0, }, + projectActionButton: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + flexShrink: 0, + }, + projectActionButtonHovered: { + backgroundColor: theme.colors.surface1, + }, + projectActionButtonText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + projectIconActionButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.md, + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + projectIconActionButtonHovered: { + backgroundColor: theme.colors.surface1, + }, + projectActionTooltipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + }, workspaceRow: { minHeight: 36, marginBottom: theme.spacing[1], @@ -1338,7 +1696,7 @@ const styles = StyleSheet.create((theme) => ({ shadowRadius: 8, shadowOffset: { width: 0, height: 4 }, }, - workspaceRowSelected: { + sidebarRowSelected: { backgroundColor: theme.colors.surface2, }, workspaceRowContainer: { diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 5b0d2add7..8a5ac7248 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -34,8 +34,9 @@ import { import { useSessionStore, type Agent, - type WorkspaceDescriptor, type SessionState, + type WorkspaceDescriptor, + normalizeWorkspaceDescriptor, } from "@/stores/session-store"; import { useDraftStore } from "@/stores/draft-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; @@ -52,7 +53,6 @@ import { normalizeAgentSnapshot, } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; -import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; @@ -130,24 +130,6 @@ type WorkspaceUpdatePayload = Extract< const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => update.kind === "remove" ? update.agentId : update.agent.id; -function normalizeWorkspaceDescriptor( - payload: Extract<WorkspaceUpdatePayload, { kind: "upsert" }>["workspace"] -): WorkspaceDescriptor { - const activityAt = payload.activityAt - ? new Date(payload.activityAt) - : null; - return { - id: normalizeWorkspaceIdentity(payload.id) ?? payload.id, - projectId: payload.projectId, - name: payload.name, - status: payload.status, - activityAt: - activityAt && !Number.isNaN(activityAt.getTime()) - ? activityAt - : null, - }; -} - // --------------------------------------------------------------------------- // Module-level pending agent updates buffer (scoped by serverId) // --------------------------------------------------------------------------- diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index a8d972cfb..7f4701e5b 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -9,7 +9,7 @@ import { takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; import { - buildHostNewAgentRoute, + buildHostOpenProjectRoute, buildHostWorkspaceAgentRoute, buildHostSettingsRoute, parseHostAgentRouteFromPathname, @@ -55,10 +55,10 @@ type CommandCenterActionDefinition = { const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ { id: "new-agent", - title: "New agent", + title: "Open project", icon: "plus", shortcutKeys: ["mod", "shift", "O"], - keywords: ["new", "new agent", "create", "start", "launch", "agent"], + keywords: ["open", "project", "folder", "workspace", "repo"], buildRoute: ({ newAgentRoute }) => newAgentRoute, }, { @@ -123,7 +123,7 @@ export function useCommandCenter() { const newAgentRoute = useMemo<Href>(() => { const serverIdFromPath = parseServerIdFromPathname(pathname) ?? fallbackServerId; - return serverIdFromPath ? (buildHostNewAgentRoute(serverIdFromPath) as Href) : "/"; + return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/"; }, [fallbackServerId, pathname]); const settingsRoute = useMemo<Href>(() => { diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 2f1940824..a2c6b21e9 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -6,7 +6,7 @@ import { useSessionStore } from "@/stores/session-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { - buildHostNewAgentRoute, + buildHostOpenProjectRoute, buildHostWorkspaceRoute, parseHostAgentRouteFromPathname, parseHostWorkspaceRouteFromPathname, @@ -99,7 +99,7 @@ export function useKeyboardShortcuts({ return true; }; - const navigateToNewAgent = (): boolean => { + const navigateToOpenProject = (): boolean => { let targetServerId = parseServerIdFromPathname(pathname); if (!targetServerId) { @@ -111,7 +111,7 @@ export function useKeyboardShortcuts({ return false; } - router.push(buildHostNewAgentRoute(targetServerId) as any); + router.push(buildHostOpenProjectRoute(targetServerId) as any); return true; }; @@ -171,7 +171,7 @@ export function useKeyboardShortcuts({ }): boolean => { switch (input.action) { case "agent.new": - return navigateToNewAgent(); + return navigateToOpenProject(); case "workspace.tab.new": return requestWorkspaceTabAction({ kind: "new" }); case "workspace.tab.close.current": diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 56f3e1ac4..bbc69dcab 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -14,6 +14,28 @@ function item(key: string): OrderedItem { return { key } } +function workspace( + input: Pick<WorkspaceDescriptor, "id" | "projectId" | "name" | "status" | "activityAt"> & + Partial< + Pick< + WorkspaceDescriptor, + "projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" + > + > +): WorkspaceDescriptor { + return { + id: input.id, + projectId: input.projectId, + projectDisplayName: input.projectDisplayName ?? input.projectId, + projectRootPath: input.projectRootPath ?? input.id, + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "local_checkout", + name: input.name, + status: input.status, + activityAt: input.activityAt, + } +} + describe('applyStoredOrdering', () => { it('keeps unknown items on the baseline while applying stored order', () => { const result = applyStoredOrdering({ @@ -72,13 +94,13 @@ describe('appendMissingOrderKeys', () => { describe('buildSidebarProjectsFromWorkspaces', () => { it('uses workspace descriptor name and status directly', () => { const workspaces: WorkspaceDescriptor[] = [ - { + workspace({ id: '/repo/main', projectId: 'project-1', name: 'feat/hard-cut', status: 'failed', activityAt: new Date('2026-01-01T00:00:00.000Z'), - }, + }), ] const projects = buildSidebarProjectsFromWorkspaces({ @@ -96,20 +118,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => { it('preserves stored project order even when activity changes', () => { const initialWorkspaces: WorkspaceDescriptor[] = [ - { + workspace({ id: '/repo/b', projectId: 'project-b', name: 'feat/b', status: 'running', activityAt: new Date('2026-01-02T00:00:00.000Z'), - }, - { + }), + workspace({ id: '/repo/a', projectId: 'project-a', name: 'feat/a', status: 'running', activityAt: new Date('2026-01-01T00:00:00.000Z'), - }, + }), ] const seededOrder = appendMissingOrderKeys({ @@ -125,20 +147,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => { const updatedProjects = buildSidebarProjectsFromWorkspaces({ serverId: 'srv', workspaces: [ - { + workspace({ id: '/repo/b', projectId: 'project-b', name: 'feat/b', status: 'running', activityAt: new Date('2026-01-02T00:00:00.000Z'), - }, - { + }), + workspace({ id: '/repo/a', projectId: 'project-a', name: 'feat/a', status: 'running', activityAt: new Date('2026-01-03T00:00:00.000Z'), - }, + }), ], projectOrder: seededOrder, workspaceOrderByScope: {}, @@ -151,27 +173,27 @@ describe('buildSidebarProjectsFromWorkspaces', () => { const projects = buildSidebarProjectsFromWorkspaces({ serverId: 'srv', workspaces: [ - { + workspace({ id: '/repo/c', projectId: 'project-c', name: 'feat/c', status: 'running', activityAt: new Date('2026-01-04T00:00:00.000Z'), - }, - { + }), + workspace({ id: '/repo/b', projectId: 'project-b', name: 'feat/b', status: 'running', activityAt: new Date('2026-01-02T00:00:00.000Z'), - }, - { + }), + workspace({ id: '/repo/a', projectId: 'project-a', name: 'feat/a', status: 'running', activityAt: new Date('2026-01-01T00:00:00.000Z'), - }, + }), ], projectOrder: ['project-b', 'project-a', 'project-c'], workspaceOrderByScope: {}, @@ -184,20 +206,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => { const initialProjects = buildSidebarProjectsFromWorkspaces({ serverId: 'srv', workspaces: [ - { + workspace({ id: '/repo/main', projectId: 'project-1', name: 'main', status: 'running', activityAt: new Date('2026-01-02T00:00:00.000Z'), - }, - { + }), + workspace({ id: '/repo/feature', projectId: 'project-1', name: 'feature', status: 'running', activityAt: new Date('2026-01-01T00:00:00.000Z'), - }, + }), ], projectOrder: ['project-1'], workspaceOrderByScope: {}, @@ -211,20 +233,20 @@ describe('buildSidebarProjectsFromWorkspaces', () => { const projects = buildSidebarProjectsFromWorkspaces({ serverId: 'srv', workspaces: [ - { + workspace({ id: '/repo/main', projectId: 'project-1', name: 'main', status: 'running', activityAt: new Date('2026-01-02T00:00:00.000Z'), - }, - { + }), + workspace({ id: '/repo/feature', projectId: 'project-1', name: 'feature', status: 'running', activityAt: new Date('2026-01-03T00:00:00.000Z'), - }, + }), ], projectOrder: ['project-1'], workspaceOrderByScope: { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index f782bf7ce..671779d7f 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,10 +1,9 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react' -import { useSessionStore } from '@/stores/session-store' +import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store' import { getHostRuntimeStore } from '@/runtime/host-runtime' import { useSidebarOrderStore } from '@/stores/sidebar-order-store' import type { WorkspaceDescriptor } from '@/stores/session-store' import { projectDisplayNameFromProjectId } from '@/utils/project-display-name' -import { normalizeWorkspaceIdentity } from '@/utils/workspace-identity' const EMPTY_ORDER: string[] = [] const EMPTY_PROJECTS: SidebarProjectEntry[] = [] @@ -15,6 +14,7 @@ export interface SidebarWorkspaceEntry { workspaceKey: string serverId: string workspaceId: string + workspaceKind: WorkspaceDescriptor['workspaceKind'] name: string activityAt: Date | null statusBucket: SidebarStateBucket @@ -23,6 +23,7 @@ export interface SidebarWorkspaceEntry { export interface SidebarProjectEntry { projectKey: string projectName: string + projectKind: WorkspaceDescriptor['projectKind'] iconWorkingDir: string statusBucket: SidebarStateBucket activeCount: number @@ -110,8 +111,9 @@ export function buildSidebarProjectsFromWorkspaces(input: { byProject.get(workspace.projectId) ?? ({ projectKey: workspace.projectId, - projectName: projectDisplayNameFromProjectId(workspace.projectId), - iconWorkingDir: workspace.id, + projectName: workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId), + projectKind: workspace.projectKind, + iconWorkingDir: workspace.projectRootPath || workspace.id, statusBucket: 'done', activeCount: 0, totalWorkspaces: 0, @@ -123,6 +125,7 @@ export function buildSidebarProjectsFromWorkspaces(input: { workspaceKey: `${input.serverId}:${workspace.id}`, serverId: input.serverId, workspaceId: workspace.id, + workspaceKind: workspace.workspaceKind, name: workspace.name, activityAt: workspace.activityAt, statusBucket: workspace.status, @@ -241,18 +244,15 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string function toWorkspaceDescriptor(payload: { id: string projectId: string + projectDisplayName: string + projectRootPath: string + projectKind: WorkspaceDescriptor['projectKind'] + workspaceKind: WorkspaceDescriptor['workspaceKind'] name: string status: WorkspaceDescriptor['status'] activityAt: string | null }): WorkspaceDescriptor { - const activityAt = payload.activityAt ? new Date(payload.activityAt) : null - return { - id: normalizeWorkspaceIdentity(payload.id) ?? payload.id, - projectId: payload.projectId, - name: payload.name, - status: payload.status, - activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null, - } + return normalizeWorkspaceDescriptor(payload) } export function useSidebarWorkspacesList(options?: { diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 253a2666e..7f5fb904a 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -133,7 +133,7 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [ help: { id: "new-agent", section: "global", - label: "Create new agent", + label: "Open project", keys: ["mod", "shift", "O"], }, }, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index cbb5b6857..3f5f07e6c 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -104,6 +104,7 @@ type DraftAgentParams = { model?: string thinkingOptionId?: string workingDir?: string + worktreeMode?: string } type DraftAgentScreenProps = { @@ -160,6 +161,11 @@ function DraftAgentScreenContent({ const resolvedModel = getParamValue(params.model) const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId) const resolvedWorkingDir = getParamValue(params.workingDir) + const resolvedWorktreeMode = getParamValue(params.worktreeMode) + const initialWorktreeMode = + resolvedWorktreeMode === 'create' || resolvedWorktreeMode === 'attach' + ? resolvedWorktreeMode + : 'none' const onlineServerIds = useMemo(() => { if (daemons.length === 0) return [] @@ -234,7 +240,9 @@ function DraftAgentScreenContent({ const draftIdRef = useRef(generateDraftId()) const draftAgentIdRef = useRef(generateDraftId()) - const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>('none') + const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>( + initialWorktreeMode + ) const [baseBranch, setBaseBranch] = useState('') const [worktreeSlug, setWorktreeSlug] = useState('') const [selectedWorktreePath, setSelectedWorktreePath] = useState('') diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx new file mode 100644 index 000000000..9362ce225 --- /dev/null +++ b/packages/app/src/screens/open-project-screen.tsx @@ -0,0 +1,206 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { View, Text, ScrollView } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { router } from "expo-router"; +import { FolderOpen } from "lucide-react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { useToast } from "@/contexts/toast-context"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { + normalizeWorkspaceDescriptor, + useSessionStore, +} from "@/stores/session-store"; +import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes"; +import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; + +export function OpenProjectScreen({ serverId }: { serverId: string }) { + const { theme } = useUnistyles(); + const toast = useToast(); + const { client, isConnected } = useHostRuntimeSession(serverId); + const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); + const [isDirectoryPickerOpen, setIsDirectoryPickerOpen] = useState(false); + const [directoryQuery, setDirectoryQuery] = useState(""); + const [selectedPath, setSelectedPath] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const directoryAnchorRef = useRef<View>(null); + + const recommendedPaths = useMemo(() => { + if (!workspaces) { + return []; + } + return Array.from(workspaces.values()).map((workspace) => workspace.projectRootPath || workspace.id); + }, [workspaces]); + + const directorySuggestionsQuery = useQuery({ + queryKey: ["open-project-directory-suggestions", serverId, directoryQuery], + queryFn: async () => { + if (!client) { + return []; + } + const result = await client.getDirectorySuggestions({ + query: directoryQuery, + includeDirectories: true, + includeFiles: false, + limit: 30, + }); + return result.entries?.flatMap((entry) => + entry.kind === "directory" ? [entry.path] : [] + ) ?? []; + }, + enabled: Boolean(client) && isConnected, + staleTime: 15_000, + retry: false, + }); + + const directoryOptions = useMemo( + () => + buildWorkingDirectorySuggestions({ + recommendedPaths, + serverPaths: directorySuggestionsQuery.data ?? [], + query: directoryQuery, + }).map((path) => ({ + id: path, + label: path, + kind: "directory" as const, + })), + [directoryQuery, directorySuggestionsQuery.data, recommendedPaths] + ); + + const handleOpenProject = useCallback(async () => { + const trimmedPath = selectedPath.trim(); + if (!trimmedPath) { + toast.error("Choose a project directory"); + return; + } + if (!client) { + toast.error("Host is not connected"); + return; + } + + setIsSubmitting(true); + try { + const payload = await client.openProject(trimmedPath); + if (payload.error || !payload.workspace) { + throw new Error(payload.error || "Failed to open project"); + } + mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(payload.workspace)]); + setHasHydratedWorkspaces(serverId, true); + router.replace( + buildHostWorkspaceRouteWithOpenIntent(serverId, payload.workspace.id, { + kind: "draft", + draftId: "new", + }) as any + ); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to open project"); + } finally { + setIsSubmitting(false); + } + }, [client, mergeWorkspaces, selectedPath, serverId, setHasHydratedWorkspaces, toast]); + + return ( + <View style={styles.container}> + <ScrollView contentContainerStyle={styles.content}> + <View style={styles.card}> + <Text style={styles.eyebrow}>Workspace Registry</Text> + <Text style={styles.title}>Open project</Text> + <Text style={styles.subtitle}> + Add a local folder to the sidebar, then land inside its workspace draft tab. + </Text> + + <FormSelectTrigger + controlRef={directoryAnchorRef} + containerStyle={styles.selector} + label="Project directory" + value={selectedPath} + placeholder="Choose a project directory" + onPress={() => setIsDirectoryPickerOpen(true)} + icon={<FolderOpen size={theme.iconSize.md} color={theme.colors.foregroundMuted} />} + showLabel={false} + valueEllipsizeMode="middle" + testID="open-project-directory-trigger" + /> + + <View style={styles.actions}> + <Button + variant="default" + onPress={() => void handleOpenProject()} + disabled={isSubmitting || !isConnected} + testID="open-project-submit" + > + {isSubmitting ? "Opening..." : "Open project"} + </Button> + </View> + </View> + </ScrollView> + + <Combobox + options={directoryOptions} + value={selectedPath} + onSelect={setSelectedPath} + onSearchQueryChange={setDirectoryQuery} + searchPlaceholder="Search directories..." + emptyText="No directories found" + allowCustomValue + customValuePrefix="" + customValueKind="directory" + optionsPosition="above-search" + title="Project directory" + open={isDirectoryPickerOpen} + onOpenChange={setIsDirectoryPickerOpen} + anchorRef={directoryAnchorRef} + /> + </View> + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + content: { + flexGrow: 1, + justifyContent: "center", + padding: theme.spacing[6], + }, + card: { + alignSelf: "center", + width: "100%", + maxWidth: 560, + gap: theme.spacing[4], + padding: theme.spacing[6], + borderRadius: theme.borderRadius.xl, + backgroundColor: theme.colors.surface1, + borderWidth: 1, + borderColor: theme.colors.border, + }, + eyebrow: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + textTransform: "uppercase", + letterSpacing: 1, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize["3xl"], + fontWeight: theme.fontWeight.semibold, + }, + subtitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + lineHeight: 20, + }, + selector: { + minHeight: 64, + }, + actions: { + flexDirection: "row", + justifyContent: "flex-start", + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 0e0d79c13..71b462896 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -116,6 +116,21 @@ function decodeSegment(value: string): string { } } +function buildOpenIntentKey(input: { + serverId: string; + workspaceId: string; + openIntent?: WorkspaceOpenIntent | null; +}): string | null { + if (!input.openIntent) { + return null; + } + const openParam = buildWorkspaceOpenIntentParam(input.openIntent); + if (!openParam) { + return null; + } + return `${input.serverId}:${input.workspaceId}:${openParam}`; +} + export function WorkspaceScreen({ serverId, workspaceId, @@ -432,18 +447,43 @@ function WorkspaceScreenContent({ (state) => state.clearWorkspaceTabActionRequest ); const consumedOpenIntentsRef = useRef(new Set<string>()); + const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState<string | null>(null); + const currentOpenIntentKey = useMemo( + () => + buildOpenIntentKey({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + openIntent, + }), + [normalizedServerId, normalizedWorkspaceId, openIntent] + ); + + useEffect(() => { + if (!currentOpenIntentKey) { + if (resolvedOpenIntentKey !== null) { + setResolvedOpenIntentKey(null); + } + return; + } + + if (resolvedOpenIntentKey === currentOpenIntentKey) { + return; + } + }, [currentOpenIntentKey, resolvedOpenIntentKey]); useEffect(() => { if (!openIntent || !persistenceKey) { return; } - const openParam = buildWorkspaceOpenIntentParam(openIntent); - if (!openParam) { + if (!currentOpenIntentKey) { return; } - const intentKey = `${normalizedServerId}:${normalizedWorkspaceId}:${openParam}`; + const intentKey = currentOpenIntentKey; if (consumedOpenIntentsRef.current.has(intentKey)) { + if (resolvedOpenIntentKey !== intentKey) { + setResolvedOpenIntentKey(intentKey); + } return; } consumedOpenIntentsRef.current.add(intentKey); @@ -468,6 +508,7 @@ function WorkspaceScreenContent({ workspaceId: normalizedWorkspaceId, tabId, }); + setResolvedOpenIntentKey(intentKey); } return; } @@ -488,8 +529,10 @@ function WorkspaceScreenContent({ workspaceId: normalizedWorkspaceId, tabId, }); + setResolvedOpenIntentKey(intentKey); } }, [ + currentOpenIntentKey, focusTab, openDraftTab, openIntent, @@ -497,8 +540,13 @@ function WorkspaceScreenContent({ persistenceKey, normalizedServerId, normalizedWorkspaceId, + resolvedOpenIntentKey, ]); + const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey + ? openIntent + : null; + useEffect(() => { if (!normalizedServerId || !normalizedWorkspaceId) { return; @@ -586,17 +634,17 @@ function WorkspaceScreenContent({ tabOrder, focusedTabId, preferredTarget: - openIntent?.kind === "agent" - ? { kind: "agent", agentId: openIntent.agentId } - : openIntent?.kind === "terminal" - ? { kind: "terminal", terminalId: openIntent.terminalId } - : openIntent?.kind === "draft" - ? { kind: "draft", draftId: openIntent.draftId } - : openIntent?.kind === "file" - ? { kind: "file", path: openIntent.path } + unresolvedOpenIntent?.kind === "agent" + ? { kind: "agent", agentId: unresolvedOpenIntent.agentId } + : unresolvedOpenIntent?.kind === "terminal" + ? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId } + : unresolvedOpenIntent?.kind === "draft" + ? { kind: "draft", draftId: unresolvedOpenIntent.draftId } + : unresolvedOpenIntent?.kind === "file" + ? { kind: "file", path: unresolvedOpenIntent.path } : null, }), - [focusedTabId, openIntent, tabOrder, terminals, uiTabs, workspaceAgents] + [focusedTabId, tabOrder, terminals, uiTabs, unresolvedOpenIntent, workspaceAgents] ); const activeTabId = tabModel.activeTabId; diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index c5959efc2..b4416b3d6 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -11,6 +11,10 @@ describe('workspace source of truth consumption', () => { const workspace: WorkspaceDescriptor = { id: '/repo/main', projectId: 'remote:github.com/getpaseo/paseo', + projectDisplayName: 'getpaseo/paseo', + projectRootPath: '/repo/main', + projectKind: 'git', + workspaceKind: 'local_checkout', name: 'feat/workspace-sot', status: 'running', activityAt: new Date('2026-03-01T00:00:00.000Z'), diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 0088786b9..c36622483 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -25,6 +25,7 @@ import type { ServerCapabilities, WorkspaceDescriptorPayload, } from "@server/shared/messages"; +import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; import { createAgentLastActivityCoalescer, @@ -112,11 +113,33 @@ export interface Agent { export interface WorkspaceDescriptor { id: string; projectId: string; + projectDisplayName: string; + projectRootPath: string; + projectKind: WorkspaceDescriptorPayload["projectKind"]; + workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; name: string; status: WorkspaceDescriptorPayload["status"]; activityAt: Date | null; } +export function normalizeWorkspaceDescriptor( + payload: WorkspaceDescriptorPayload +): WorkspaceDescriptor { + const activityAt = payload.activityAt ? new Date(payload.activityAt) : null; + return { + id: normalizeWorkspaceIdentity(payload.id) ?? payload.id, + projectId: payload.projectId, + projectDisplayName: payload.projectDisplayName, + projectRootPath: payload.projectRootPath, + projectKind: payload.projectKind, + workspaceKind: payload.workspaceKind, + name: payload.name, + status: payload.status, + activityAt: + activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null, + }; +} + export type ExplorerEntryKind = "file" | "directory"; export type ExplorerFileKind = "text" | "image" | "binary"; export type ExplorerEncoding = "utf-8" | "base64" | "none"; diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 2bc5688c4..c153cfae3 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -196,4 +196,42 @@ describe("terminal-emulator-runtime", () => { expect(terminal.options?.theme).toEqual({ background: "after" }); expect(refresh).toHaveBeenCalledWith(0, 11); }); + + it("forces a refit when the page becomes visible again", () => { + const runtime = new TerminalEmulatorRuntime(); + const fitAndEmitResize = vi.fn(); + + (runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = + fitAndEmitResize; + (globalThis as { document?: { visibilityState?: string } }).document = { + visibilityState: "visible", + }; + + ( + runtime as unknown as { + handleVisibilityRestore: () => void; + } + ).handleVisibilityRestore(); + + expect(fitAndEmitResize).toHaveBeenCalledWith(true); + }); + + it("does not refit while the page is still hidden", () => { + const runtime = new TerminalEmulatorRuntime(); + const fitAndEmitResize = vi.fn(); + + (runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = + fitAndEmitResize; + (globalThis as { document?: { visibilityState?: string } }).document = { + visibilityState: "hidden", + }; + + ( + runtime as unknown as { + handleVisibilityRestore: () => void; + } + ).handleVisibilityRestore(); + + expect(fitAndEmitResize).not.toHaveBeenCalled(); + }); }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index 38f9bb3b0..2d84d0b4a 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -36,6 +36,8 @@ type TerminalEmulatorRuntimeDisposables = { disposeInput: () => void; disconnectResizeObserver: () => void; removeWindowResize: () => void; + removeWindowFocus: () => void; + removeDocumentVisibilityChange: () => void; removeVisualViewportResize: () => void; clearFitInterval: () => void; clearFitTimeouts: () => void; @@ -102,6 +104,19 @@ export class TerminalEmulatorRuntime { private inFlightOutputOperationTimeout: ReturnType<typeof setTimeout> | null = null; private suppressInput = false; + private handleVisibilityRestore = (): void => { + if (typeof document !== "undefined" && document.visibilityState !== "visible") { + return; + } + + this.fitAndEmitResize?.(true); + if (typeof window.requestAnimationFrame === "function") { + window.requestAnimationFrame(() => { + this.fitAndEmitResize?.(true); + }); + } + }; + setCallbacks(input: { callbacks: TerminalEmulatorRuntimeCallbacks }): void { this.callbacks = input.callbacks; } @@ -311,6 +326,15 @@ export class TerminalEmulatorRuntime { const windowResizeHandler = () => fitAndEmitResize(false); window.addEventListener("resize", windowResizeHandler); + const windowFocusHandler = () => { + this.handleVisibilityRestore(); + }; + window.addEventListener("focus", windowFocusHandler); + + const documentVisibilityChangeHandler = () => { + this.handleVisibilityRestore(); + }; + document.addEventListener("visibilitychange", documentVisibilityChangeHandler); const visualViewport = window.visualViewport; const visualViewportResizeHandler = () => fitAndEmitResize(false); @@ -369,6 +393,12 @@ export class TerminalEmulatorRuntime { removeWindowResize: () => { window.removeEventListener("resize", windowResizeHandler); }, + removeWindowFocus: () => { + window.removeEventListener("focus", windowFocusHandler); + }, + removeDocumentVisibilityChange: () => { + document.removeEventListener("visibilitychange", documentVisibilityChangeHandler); + }, removeVisualViewportResize: () => { visualViewport?.removeEventListener("resize", visualViewportResizeHandler); }, @@ -410,6 +440,8 @@ export class TerminalEmulatorRuntime { disposables.disposeInput(); disposables.disconnectResizeObserver(); disposables.removeWindowResize(); + disposables.removeWindowFocus(); + disposables.removeDocumentVisibilityChange(); disposables.removeVisualViewportResize(); disposables.clearFitInterval(); disposables.clearFitTimeouts(); diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index fad43fd36..b1c287ac5 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -68,6 +68,12 @@ describe("workspace route parsing", () => { it("builds host new-agent routes", () => { expect(buildHostNewAgentRoute("local")).toBe("/h/local/new-agent"); + expect( + buildHostNewAgentRoute("local", { + workingDir: "/tmp/repo", + worktreeMode: "create", + }) + ).toBe("/h/local/new-agent?workingDir=%2Ftmp%2Frepo&worktreeMode=create"); }); it("builds workspace routes with open intent query", () => { diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 40d09d296..e6d076806 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -399,12 +399,46 @@ export function buildHostAgentsRoute(serverId: string): string { return `${base}/agents`; } -export function buildHostNewAgentRoute(serverId: string): string { +export function buildHostNewAgentRoute( + serverId: string, + options?: { + workingDir?: string; + provider?: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + worktreeMode?: "create" | "attach"; + } +): string { const base = buildHostRootRoute(serverId); if (base === "/") { return "/"; } - return `${base}/new-agent`; + const searchParams = new URLSearchParams(); + const entries: Array<[string, string | undefined]> = [ + ["workingDir", options?.workingDir], + ["provider", options?.provider], + ["modeId", options?.modeId], + ["model", options?.model], + ["thinkingOptionId", options?.thinkingOptionId], + ["worktreeMode", options?.worktreeMode], + ]; + for (const [key, value] of entries) { + const normalized = trimNonEmpty(value); + if (normalized) { + searchParams.set(key, normalized); + } + } + const search = searchParams.toString(); + return search ? `${base}/new-agent?${search}` : `${base}/new-agent`; +} + +export function buildHostOpenProjectRoute(serverId: string): string { + const base = buildHostRootRoute(serverId); + if (base === "/") { + return "/"; + } + return `${base}/open-project`; } export function buildHostSettingsRoute(serverId: string): string { @@ -435,6 +469,9 @@ export function mapPathnameToServer( if (suffix.startsWith("new-agent")) { return `${base}/new-agent`; } + if (suffix.startsWith("open-project")) { + return `${base}/open-project`; + } const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); if (workspaceRoute) { const workspacePath = buildHostWorkspaceRoute( diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts new file mode 100644 index 000000000..d1cba0604 --- /dev/null +++ b/packages/app/src/utils/sidebar-project-row-model.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { buildSidebarProjectRowModel } from './sidebar-project-row-model' +import type { + SidebarProjectEntry, + SidebarWorkspaceEntry, +} from '@/hooks/use-sidebar-workspaces-list' + +function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorkspaceEntry { + return { + workspaceKey: 'srv:/repo', + serverId: 'srv', + workspaceId: '/repo', + workspaceKind: 'directory', + name: 'paseo', + activityAt: null, + statusBucket: 'done', + ...overrides, + } +} + +function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEntry { + return { + projectKey: 'project-1', + projectName: 'paseo', + projectKind: 'git', + iconWorkingDir: '/repo', + statusBucket: 'done', + activeCount: 0, + totalWorkspaces: 1, + latestActivityAt: null, + workspaces: [workspace()], + ...overrides, + } +} + +describe('buildSidebarProjectRowModel', () => { + it('flattens non-git projects with one workspace into a direct workspace row model', () => { + const flattenedWorkspace = workspace({ + workspaceId: '/repo/non-git', + workspaceKind: 'directory', + statusBucket: 'running', + }) + + const result = buildSidebarProjectRowModel({ + project: project({ + projectKind: 'non_git', + workspaces: [flattenedWorkspace], + }), + collapsed: false, + }) + + expect(result).toEqual({ + interaction: 'navigate', + chevron: 'disclosure', + trailingAction: 'none', + flattenedWorkspace, + selected: false, + }) + }) + + it('marks flattened non-git project rows as selected when their workspace is active', () => { + const flattenedWorkspace = workspace({ + serverId: 'srv-2', + workspaceId: '/repo/non-git', + }) + + const result = buildSidebarProjectRowModel({ + project: project({ + projectKind: 'non_git', + workspaces: [flattenedWorkspace], + }), + collapsed: false, + serverId: 'srv-2', + activeWorkspaceSelection: { + serverId: 'srv-2', + workspaceId: '/repo/non-git', + }, + }) + + expect(result.selected).toBe(true) + }) + + it('keeps git projects as expandable sections with a new worktree action', () => { + const result = buildSidebarProjectRowModel({ + project: project({ + projectKind: 'git', + workspaces: [ + workspace({ workspaceId: '/repo/main', workspaceKind: 'local_checkout' }), + workspace({ workspaceId: '/repo/feature', workspaceKind: 'worktree' }), + ], + }), + collapsed: true, + }) + + expect(result).toEqual({ + interaction: 'toggle', + chevron: 'expand', + trailingAction: 'new_worktree', + flattenedWorkspace: null, + selected: false, + }) + }) +}) diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts new file mode 100644 index 000000000..5fcc8cba6 --- /dev/null +++ b/packages/app/src/utils/sidebar-project-row-model.ts @@ -0,0 +1,47 @@ +import type { + SidebarProjectEntry, + SidebarWorkspaceEntry, +} from '@/hooks/use-sidebar-workspaces-list' + +export interface SidebarProjectRowModel { + interaction: 'toggle' | 'navigate' + chevron: 'expand' | 'collapse' | 'disclosure' + trailingAction: 'new_worktree' | 'none' + flattenedWorkspace: SidebarWorkspaceEntry | null + selected: boolean +} + +export function buildSidebarProjectRowModel(input: { + project: SidebarProjectEntry + collapsed: boolean + serverId?: string | null + activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null +}): SidebarProjectRowModel { + const flattenedWorkspace = + input.project.projectKind === 'non_git' && input.project.workspaces.length === 1 + ? input.project.workspaces[0] ?? null + : null + const selected = + flattenedWorkspace !== null && + Boolean(input.serverId) && + input.activeWorkspaceSelection?.serverId === input.serverId && + input.activeWorkspaceSelection?.workspaceId === flattenedWorkspace.workspaceId + + if (flattenedWorkspace) { + return { + interaction: 'navigate', + chevron: 'disclosure', + trailingAction: 'none', + flattenedWorkspace, + selected, + } + } + + return { + interaction: 'toggle', + chevron: input.collapsed ? 'expand' : 'collapse', + trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none', + flattenedWorkspace: null, + selected: false, + } +} diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index b143db874..32155f78e 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -8,6 +8,7 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { workspaceKey: `${serverId}:${cwd}`, serverId, workspaceId: cwd, + workspaceKind: "local_checkout", name: cwd, activityAt: null, statusBucket: "done", @@ -18,6 +19,7 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb return { projectKey, projectName: projectKey, + projectKind: "git", iconWorkingDir: workspaces[0]?.workspaceId ?? "", statusBucket: "done", activeCount: 0, diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index e1ac5b7d1..dc9281e06 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -33,6 +33,8 @@ import type { PaseoWorktreeListResponse, PaseoWorktreeArchiveResponse, ProjectIconResponse, + OpenProjectResponseMessage, + ArchiveWorkspaceResponseMessage, ListCommandsResponse, ListProviderModelsResponseMessage, ListAvailableProvidersResponse, @@ -305,6 +307,8 @@ export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, 'type' | 'requ } export type FetchWorkspacesEntry = FetchWorkspacesPayload['entries'][number] export type FetchWorkspacesPageInfo = FetchWorkspacesPayload['pageInfo'] +type OpenProjectPayload = OpenProjectResponseMessage['payload'] +type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage['payload'] export type FetchAgentResult = { agent: AgentSnapshotPayload @@ -1143,6 +1147,30 @@ export class DaemonClient { }) } + async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: 'open_project_request', + cwd, + }, + responseType: 'open_project_response', + timeout: 10000, + }) + } + + async archiveWorkspace(workspaceId: string, requestId?: string): Promise<ArchiveWorkspacePayload> { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: 'archive_workspace_request', + workspaceId, + }, + responseType: 'archive_workspace_response', + timeout: 10000, + }) + } + async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> { const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index 29b115b23..3d2e550fe 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -979,6 +979,177 @@ describe("ClaudeAgentSession interrupt restart regression", () => { await session.close(); }); + test("does not emit live autonomous turn events for local_agent task_started during a foreground run", async () => { + const logger = createTestLogger(); + const keepQueryAlive = deferred<void>(); + + sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => { + const readPromptUuid = createPromptUuidReader(prompt); + let step = 0; + return { + next: vi.fn(async () => { + if (step === 0) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "init", + session_id: "task-started-live-session", + permissionMode: "default", + model: "opus", + }, + }; + } + if (step === 1) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { + id: "tool-call-msg", + content: [ + { + type: "tool_use", + id: "toolu_live_1", + name: "Agent", + input: { description: "verify", prompt: "sub-task" }, + }, + ], + }, + }, + }; + } + if (step === 2) { + step += 1; + return { + done: false, + value: { + type: "system", + subtype: "task_started", + task_id: "task-live-1", + tool_use_id: "toolu_live_1", + description: "verify", + task_type: "local_agent", + session_id: "task-started-live-session", + uuid: "task-started-live-1", + }, + }; + } + if (step === 3) { + step += 1; + return { + done: false, + value: { + type: "stream_event", + event: { + type: "content_block_start", + index: 2, + content_block: { + type: "tool_use", + id: "toolu_live_2", + name: "Agent", + input: {}, + caller: { type: "direct" }, + }, + }, + session_id: "task-started-live-session", + parent_tool_use_id: null, + uuid: "content-block-start-live-tool-use", + }, + }; + } + if (step === 4) { + step += 1; + const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid"; + return { + done: false, + value: { + type: "user", + message: { role: "user", content: "current prompt" }, + parent_tool_use_id: null, + uuid: promptUuid, + session_id: "task-started-live-session", + isReplay: true, + }, + }; + } + if (step === 5) { + step += 1; + return { + done: false, + value: { + type: "assistant", + message: { + content: "FOREGROUND_DONE", + }, + }, + }; + } + if (step === 6) { + step += 1; + return { + done: false, + value: { + type: "result", + subtype: "success", + usage: buildUsage(), + total_cost_usd: 0, + }, + }; + } + if (step === 7) { + await keepQueryAlive.promise; + return { done: true, value: undefined }; + } + return { done: true, value: undefined }; + }), + interrupt: vi.fn(async () => undefined), + return: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + } satisfies QueryMock; + }); + + const client = new ClaudeAgentClient({ logger }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const foregroundEvents = await collectUntilTerminal(session.stream("current prompt")); + const liveIterator = ( + session as unknown as { + streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>; + } + ).streamLiveEvents(); + const timedReader = createTimedIteratorReader({ iterator: liveIterator }); + const liveEvents: AgentStreamEvent[] = []; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const next = await timedReader.nextWithTimeout(25); + if (next.done) { + break; + } + liveEvents.push(next.value); + } catch { + break; + } + } + + expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_DONE"); + expect(liveEvents.some((event) => event.type === "turn_started")).toBe(false); + expect(liveEvents.some((event) => event.type === "turn_completed")).toBe(false); + + keepQueryAlive.resolve(undefined); + await session.close(); + }); + test("emits autonomous live events from SDK stream when Claude wakes itself", async () => { const logger = createTestLogger(); let queryCreateCount = 0; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index aef64544f..66cbfeffe 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -50,6 +50,11 @@ import { AgentStorage } from "./agent/agent-storage.js"; import { attachAgentStoragePersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; import { createAllClients, shutdownProviders } from "./agent/provider-registry.js"; +import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; +import { + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, +} from "./workspace-registry.js"; import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; import { createConnectionOfferV2, @@ -289,6 +294,14 @@ export async function createPaseoDaemon( const httpServer = createHTTPServer(app); const agentStorage = new AgentStorage(config.agentStoragePath, logger); + const projectRegistry = new FileBackedProjectRegistry( + path.join(config.paseoHome, "projects", "projects.json"), + logger + ); + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(config.paseoHome, "projects", "workspaces.json"), + logger + ); const agentManager = new AgentManager({ clients: { ...createAllClients(logger, { @@ -308,6 +321,13 @@ export async function createPaseoDaemon( agentStorage ); await agentStorage.initialize(); + await bootstrapWorkspaceRegistries({ + paseoHome: config.paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + logger, + }); const persistedRecords = await agentStorage.list(); logger.info( `Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand` @@ -532,7 +552,9 @@ export async function createPaseoDaemon( } catch (error) { logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent"); } - } + }, + projectRegistry, + workspaceRegistry ); unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => { wsServer?.publishSpeechReadiness(snapshot); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 687d9f325..6badeb111 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -28,7 +28,6 @@ import { type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, type DirectorySuggestionsRequest, - type ProjectCheckoutLitePayload, type ProjectPlacementPayload, type WorkspaceDescriptorPayload, type WorkspaceStateBucket, @@ -95,6 +94,24 @@ import type { } from './agent/agent-sdk-types.js' import { AgentStorage, type StoredAgentRecord } from './agent/agent-storage.js' import { isValidAgentProvider, AGENT_PROVIDER_IDS } from './agent/provider-manifest.js' +import { + buildProjectPlacementForCwd, + deriveProjectKind, + deriveProjectRootPath, + deriveWorkspaceDisplayName, + deriveWorkspaceKind, + normalizeWorkspaceId as normalizePersistedWorkspaceId, +} from './workspace-registry-model.js' +import type { + PersistedProjectRecord, + PersistedWorkspaceRecord, + ProjectRegistry, + WorkspaceRegistry, +} from './workspace-registry.js' +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, +} from './workspace-registry.js' import { buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, @@ -121,7 +138,6 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from './worktree-boots import { getCheckoutDiff, getCheckoutStatus, - getCheckoutStatusLite, listBranchSuggestions, NotGitRepoError, MergeConflictError, @@ -194,82 +210,6 @@ export function resolveCreateAgentTitles(options: { } } -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 path: string | null = null - - const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/) - if (scpLike) { - host = scpLike[1] ?? null - path = scpLike[2] ?? null - } else if (trimmed.includes('://')) { - try { - const parsed = new URL(trimmed) - host = parsed.hostname || null - path = parsed.pathname ? parsed.pathname.replace(/^\//, '') : null - } catch { - return null - } - } - - if (!host || !path) { - return null - } - - let cleanedPath = path.trim().replace(/^\/+/, '').replace(/\/+$/, '') - if (cleanedPath.endsWith('.git')) { - cleanedPath = cleanedPath.slice(0, -4) - } - if (!cleanedPath.includes('/')) { - return null - } - - const cleanedHost = host.toLowerCase() - if (cleanedHost === 'github.com') { - return `remote:github.com/${cleanedPath}` - } - - return `remote:${cleanedHost}/${cleanedPath}` -} - -function deriveProjectGroupingKey(options: { - cwd: string - remoteUrl: string | null - isPaseoOwnedWorktree: boolean - mainRepoRoot: string | null -}): string { - const remoteKey = deriveRemoteProjectKey(options.remoteUrl) - if (remoteKey) { - return remoteKey - } - - const mainRepoRoot = options.mainRepoRoot?.trim() - if (options.isPaseoOwnedWorktree && mainRepoRoot) { - return mainRepoRoot - } - - return options.cwd -} - -function deriveProjectGroupingName(projectKey: string): string { - const githubRemotePrefix = 'remote:github.com/' - if (projectKey.startsWith(githubRemotePrefix)) { - return projectKey.slice(githubRemotePrefix.length) || projectKey - } - - const segments = projectKey.split(/[\\/]/).filter(Boolean) - return segments[segments.length - 1] || projectKey -} - type ProcessingPhase = 'idle' | 'transcribing' type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest['compare'] @@ -431,6 +371,8 @@ export type SessionOptions = { paseoHome: string agentManager: AgentManager agentStorage: AgentStorage + projectRegistry: ProjectRegistry + workspaceRegistry: WorkspaceRegistry createAgentMcpTransport: AgentMcpTransportFactory stt: Resolvable<SpeechToTextProvider | null> tts: Resolvable<TextToSpeechProvider | null> @@ -618,6 +560,8 @@ export class Session { private agentTools: ToolSet | null = null private agentManager: AgentManager private readonly agentStorage: AgentStorage + private readonly projectRegistry: ProjectRegistry + private readonly workspaceRegistry: WorkspaceRegistry private readonly createAgentMcpTransport: AgentMcpTransportFactory private readonly downloadTokenStore: DownloadTokenStore private readonly pushTokenStore: PushTokenStore @@ -682,6 +626,8 @@ export class Session { paseoHome, agentManager, agentStorage, + projectRegistry, + workspaceRegistry, createAgentMcpTransport, stt, tts, @@ -701,6 +647,8 @@ export class Session { this.paseoHome = paseoHome this.agentManager = agentManager this.agentStorage = agentStorage + this.projectRegistry = projectRegistry + this.workspaceRegistry = workspaceRegistry this.createAgentMcpTransport = createAgentMcpTransport this.terminalManager = terminalManager if (this.terminalManager) { @@ -1292,65 +1240,16 @@ export class Session { } } - private buildFallbackProjectCheckout(cwd: string): ProjectCheckoutLitePayload { - return { - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - } - } - - private toProjectCheckoutLite( - cwd: string, - status: Awaited<ReturnType<typeof getCheckoutStatusLite>> - ): ProjectCheckoutLitePayload { - if (!status.isGit) { - return this.buildFallbackProjectCheckout(cwd) - } - - if (status.isPaseoOwnedWorktree) { - return { - cwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - isPaseoOwnedWorktree: true, - mainRepoRoot: status.mainRepoRoot, - } - } - - return { - cwd, - isGit: true, - currentBranch: status.currentBranch, - remoteUrl: status.remoteUrl, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - } - } - private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> { - const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome }) - .then((status) => this.toProjectCheckoutLite(cwd, status)) - .catch(() => this.buildFallbackProjectCheckout(cwd)) - const projectKey = deriveProjectGroupingKey({ + return buildProjectPlacementForCwd({ cwd, - remoteUrl: checkout.remoteUrl, - isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, - mainRepoRoot: checkout.mainRepoRoot, + paseoHome: this.paseoHome, }) - return { - projectKey, - projectName: deriveProjectGroupingName(projectKey), - checkout, - } } private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> { try { + await this.ensureWorkspaceRegistered(agent.cwd) const subscription = this.agentUpdatesSubscription const payload = await this.buildAgentPayload(agent) if (subscription) { @@ -1572,6 +1471,14 @@ export class Session { await this.handlePaseoWorktreeArchiveRequest(msg) break + case 'open_project_request': + await this.handleOpenProjectRequest(msg) + break + + case 'archive_workspace_request': + await this.handleArchiveWorkspaceRequest(msg) + break + case 'file_explorer_request': await this.handleFileExplorerRequest(msg) break @@ -1874,7 +1781,7 @@ export class Session { private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> { this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`) - const { archivedAt, archivedRecord } = await this.archiveAgentState(agentId) + const { archivedAt } = await this.archiveAgentState(agentId) this.emit({ type: 'agent_archived', @@ -1884,12 +1791,6 @@ export class Session { requestId, }, }) - - await this.maybeArchiveWorktreeAfterLastAgentArchived({ - archivedAgentId: agentId, - archivedAgentCwd: archivedRecord.cwd, - requestId, - }) } private async archiveAgentState(agentId: string): Promise<{ @@ -2626,6 +2527,7 @@ export class Session { worktreeName, labels ) + await this.ensureWorkspaceRegistered(sessionConfig.cwd) const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels }) await this.forwardAgentUpdate(snapshot) @@ -4570,74 +4472,6 @@ export class Session { } } - private async maybeArchiveWorktreeAfterLastAgentArchived(options: { - archivedAgentId: string - archivedAgentCwd: string - requestId: string - }): Promise<void> { - try { - const ownership = await isPaseoOwnedWorktreeCwd(options.archivedAgentCwd, { - paseoHome: this.paseoHome, - }) - if (!ownership.allowed) { - return - } - - const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(options.archivedAgentCwd, { - paseoHome: this.paseoHome, - }) - if (!resolvedWorktree) { - return - } - - const records = await this.agentStorage.list() - const recordsById = new Map(records.map((record) => [record.id, record])) - const targetPath = resolvedWorktree.worktreePath - const hasRemainingNonArchivedRecord = records.some((record) => { - if (record.id === options.archivedAgentId || record.archivedAt) { - return false - } - return this.isPathWithinRoot(targetPath, record.cwd) - }) - if (hasRemainingNonArchivedRecord) { - return - } - - const hasUnknownLiveAgent = this.agentManager.listAgents().some((agent) => { - if (agent.id === options.archivedAgentId) { - return false - } - if (!this.isPathWithinRoot(targetPath, agent.cwd)) { - return false - } - return !recordsById.has(agent.id) - }) - if (hasUnknownLiveAgent) { - return - } - - const repoRoot = ownership.repoRoot - if (!repoRoot) { - this.sessionLogger.warn( - { agentId: options.archivedAgentId, worktreePath: targetPath }, - 'Unable to resolve repo root for auto-archive after agent archive' - ) - return - } - - await this.archivePaseoWorktree({ - targetPath, - repoRoot, - requestId: options.requestId, - }) - } catch (error: any) { - this.sessionLogger.warn( - { err: error, agentId: options.archivedAgentId, cwd: options.archivedAgentCwd }, - 'Failed to auto-archive worktree after agent archive' - ) - } - } - private async archivePaseoWorktree(options: { targetPath: string repoRoot: string @@ -4653,11 +4487,13 @@ export class Session { const removedAgents = new Set<string>() const affectedWorkspaceCwds = new Set<string>([targetPath]) + const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]) const agents = this.agentManager.listAgents() for (const agent of agents) { if (this.isPathWithinRoot(targetPath, agent.cwd)) { removedAgents.add(agent.id) affectedWorkspaceCwds.add(agent.cwd) + affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd)) try { await this.agentManager.closeAgent(agent.id) } catch { @@ -4676,6 +4512,7 @@ export class Session { if (this.isPathWithinRoot(targetPath, record.cwd)) { removedAgents.add(record.id) affectedWorkspaceCwds.add(record.cwd) + affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd)) try { await this.agentStorage.remove(record.id) } catch { @@ -4692,6 +4529,10 @@ export class Session { paseoHome: this.paseoHome, }) + for (const workspaceId of affectedWorkspaceIds) { + await this.archiveWorkspaceRecord(workspaceId) + } + for (const agentId of removedAgents) { this.emit({ type: 'agent_deleted', @@ -5364,14 +5205,6 @@ export class Session { done: 4, } - private normalizeWorkspaceId(cwd: string): string { - const trimmed = cwd.trim() - if (!trimmed) { - return cwd - } - return resolve(trimmed) - } - private deriveWorkspaceStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket { const pendingPermissionCount = agent.pendingPermissions?.length ?? 0 if (pendingPermissionCount > 0 || agent.attentionReason === 'permission') { @@ -5389,23 +5222,6 @@ export class Session { return 'done' } - private deriveWorkspaceDirectoryName(cwd: string): string { - const normalized = cwd.replace(/\\/g, '/') - const segments = normalized.split('/').filter(Boolean) - return segments[segments.length - 1] ?? cwd - } - - private deriveWorkspaceName(input: { - cwd: string - checkout: ProjectCheckoutLitePayload - }): string { - const branch = input.checkout.currentBranch?.trim() ?? null - if (branch && branch.toUpperCase() !== 'HEAD') { - return branch - } - return this.deriveWorkspaceDirectoryName(input.cwd) - } - private accumulateLatestActivityAt( current: string | null, agent: AgentSnapshotPayload @@ -5425,20 +5241,55 @@ export class Session { return current } - private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> { - const agents = await this.listAgentPayloads() + private async describeWorkspaceRecord( + workspace: PersistedWorkspaceRecord, + projectRecord?: PersistedProjectRecord | null + ): Promise<WorkspaceDescriptorPayload> { + const resolvedProjectRecord = projectRecord ?? (await this.projectRegistry.get(workspace.projectId)) + let displayName = workspace.displayName + try { + const placement = await this.buildProjectPlacement(workspace.cwd) + displayName = deriveWorkspaceDisplayName({ + cwd: workspace.cwd, + checkout: placement.checkout, + }) + } catch { + // Fall back to the persisted label if checkout metadata is unavailable. + } + return { + id: workspace.workspaceId, + projectId: workspace.projectId, + projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId, + projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, + projectKind: resolvedProjectRecord?.kind ?? 'non_git', + workspaceKind: workspace.kind, + name: displayName, + status: 'done', + activityAt: null, + } + } + + private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> { + const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([ + this.listAgentPayloads(), + this.workspaceRegistry.list(), + this.projectRegistry.list(), + ]) + + const activeRecords = persistedWorkspaces.filter((workspace) => !workspace.archivedAt) + const activeProjects = new Map( + persistedProjects + .filter((project) => !project.archivedAt) + .map((project) => [project.projectId, project] as const) + ) const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>() - const placementByWorkspaceId = new Map<string, Promise<ProjectPlacementPayload>>() - const getPlacement = (workspaceCwd: string): Promise<ProjectPlacementPayload> => { - const key = this.normalizeWorkspaceId(workspaceCwd) - const existing = placementByWorkspaceId.get(key) - if (existing) { - return existing - } - const next = this.buildProjectPlacement(workspaceCwd) - placementByWorkspaceId.set(key, next) - return next + + for (const workspace of activeRecords) { + descriptorsByWorkspaceId.set( + workspace.workspaceId, + await this.describeWorkspaceRecord(workspace, activeProjects.get(workspace.projectId) ?? null) + ) } for (const agent of agents) { @@ -5446,21 +5297,9 @@ export class Session { continue } - const workspaceId = this.normalizeWorkspaceId(agent.cwd) - const placement = await getPlacement(workspaceId) + const workspaceId = normalizePersistedWorkspaceId(agent.cwd) const existing = descriptorsByWorkspaceId.get(workspaceId) if (!existing) { - const bucket = this.deriveWorkspaceStateBucket(agent) - descriptorsByWorkspaceId.set(workspaceId, { - id: workspaceId, - projectId: placement.projectKey, - name: this.deriveWorkspaceName({ - cwd: workspaceId, - checkout: placement.checkout, - }), - status: bucket, - activityAt: this.accumulateLatestActivityAt(null, agent), - }) continue } @@ -5751,13 +5590,71 @@ export class Session { } } + private async ensureWorkspaceRegistered(cwd: string): Promise<PersistedWorkspaceRecord> { + const workspaceId = normalizePersistedWorkspaceId(cwd) + const existing = await this.workspaceRegistry.get(workspaceId) + if (existing && !existing.archivedAt) { + return existing + } + + const placement = await this.buildProjectPlacement(workspaceId) + const now = new Date().toISOString() + const projectExisting = await this.projectRegistry.get(placement.projectKey) + const projectRecord: PersistedProjectRecord = createPersistedProjectRecord({ + projectId: placement.projectKey, + rootPath: deriveProjectRootPath({ + cwd: workspaceId, + checkout: placement.checkout, + }), + kind: deriveProjectKind(placement.checkout), + displayName: placement.projectName, + createdAt: projectExisting?.createdAt ?? now, + updatedAt: now, + archivedAt: null, + }) + await this.projectRegistry.upsert(projectRecord) + + const workspaceRecord = createPersistedWorkspaceRecord({ + workspaceId, + projectId: placement.projectKey, + cwd: workspaceId, + kind: deriveWorkspaceKind(placement.checkout), + displayName: deriveWorkspaceDisplayName({ + cwd: workspaceId, + checkout: placement.checkout, + }), + createdAt: existing?.createdAt ?? now, + updatedAt: now, + archivedAt: null, + }) + await this.workspaceRegistry.upsert(workspaceRecord) + return workspaceRecord + } + + private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> { + const existing = await this.workspaceRegistry.get(workspaceId) + if (!existing || existing.archivedAt) { + return + } + + const nextArchivedAt = archivedAt ?? new Date().toISOString() + await this.workspaceRegistry.archive(workspaceId, nextArchivedAt) + + const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt + ) + if (siblingWorkspaces.length === 0) { + await this.projectRegistry.archive(existing.projectId, nextArchivedAt) + } + } + private async emitWorkspaceUpdateForCwd(cwd: string): Promise<void> { const subscription = this.workspaceUpdatesSubscription if (!subscription) { return } - const workspaceId = this.normalizeWorkspaceId(cwd) + const workspaceId = normalizePersistedWorkspaceId(cwd) const all = await this.listWorkspaceDescriptors() const workspace = all.find((entry) => entry.id === workspaceId) if (!workspace) { @@ -5789,7 +5686,7 @@ export class Session { const uniqueWorkspaceCwds = new Set<string>() for (const cwd of cwds) { - const normalized = this.normalizeWorkspaceId(cwd) + const normalized = normalizePersistedWorkspaceId(cwd) if (!normalized) { continue } @@ -5923,6 +5820,76 @@ export class Session { } } + private async handleOpenProjectRequest( + request: Extract<SessionInboundMessage, { type: 'open_project_request' }> + ): Promise<void> { + try { + const workspace = await this.ensureWorkspaceRegistered(request.cwd) + await this.emitWorkspaceUpdateForCwd(workspace.cwd) + const descriptor = await this.describeWorkspaceRecord(workspace) + this.emit({ + type: 'open_project_response', + payload: { + requestId: request.requestId, + workspace: descriptor, + error: null, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to open project' + this.sessionLogger.error({ err: error, cwd: request.cwd }, 'Failed to open project') + this.emit({ + type: 'open_project_response', + payload: { + requestId: request.requestId, + workspace: null, + error: message, + }, + }) + } + } + + private async handleArchiveWorkspaceRequest( + request: Extract<SessionInboundMessage, { type: 'archive_workspace_request' }> + ): Promise<void> { + try { + const existing = await this.workspaceRegistry.get(request.workspaceId) + if (!existing) { + throw new Error(`Workspace not found: ${request.workspaceId}`) + } + if (existing.kind === 'worktree') { + throw new Error('Use worktree archive for Paseo worktrees') + } + const archivedAt = new Date().toISOString() + await this.archiveWorkspaceRecord(request.workspaceId, archivedAt) + await this.emitWorkspaceUpdateForCwd(existing.cwd) + this.emit({ + type: 'archive_workspace_response', + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + archivedAt, + error: null, + }, + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to archive workspace' + this.sessionLogger.error( + { err: error, workspaceId: request.workspaceId }, + 'Failed to archive workspace' + ) + this.emit({ + type: 'archive_workspace_response', + payload: { + requestId: request.requestId, + workspaceId: request.workspaceId, + archivedAt: null, + error: message, + }, + }) + } + } + private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> { const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier) if (!resolved.ok) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 9fd82fcaa..5bbaa9dd9 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from 'vitest' import { Session } from './session.js' import type { AgentSnapshotPayload } from '../shared/messages.js' +import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from './workspace-registry.js' function makeAgent(input: { id: string @@ -79,6 +80,24 @@ function createSessionForWorkspaceTests(): Session { list: async () => [], get: async () => null, } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, createAgentMcpTransport: async () => { throw new Error('not used') }, @@ -91,6 +110,17 @@ function createSessionForWorkspaceTests(): Session { describe('workspace aggregation', () => { test('non-git workspace uses deterministic directory name and no unknown branch fallback', async () => { const session = createSessionForWorkspaceTests() as any + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: '/tmp/non-git', + projectId: '/tmp/non-git', + cwd: '/tmp/non-git', + kind: 'directory', + displayName: 'non-git', + createdAt: '2026-03-01T12:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }), + ] session.listAgentPayloads = async () => [ makeAgent({ id: 'a1', @@ -99,19 +129,6 @@ describe('workspace aggregation', () => { updatedAt: '2026-03-01T12:00:00.000Z', }), ] - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: 'non-git', - checkout: { - cwd, - isGit: false, - currentBranch: null, - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }) - const result = await session.listFetchWorkspacesEntries({ type: 'fetch_workspaces_request', requestId: 'req-1', @@ -124,6 +141,17 @@ describe('workspace aggregation', () => { test('git branch workspace uses branch as canonical name', async () => { const session = createSessionForWorkspaceTests() as any + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: '/tmp/repo-branch', + projectId: '/tmp/repo-branch', + cwd: '/tmp/repo-branch', + kind: 'local_checkout', + displayName: 'feature/name-from-server', + createdAt: '2026-03-01T12:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }), + ] session.listAgentPayloads = async () => [ makeAgent({ id: 'a1', @@ -144,7 +172,6 @@ describe('workspace aggregation', () => { mainRepoRoot: null, }, }) - const result = await session.listFetchWorkspacesEntries({ type: 'fetch_workspaces_request', requestId: 'req-branch', @@ -156,6 +183,17 @@ describe('workspace aggregation', () => { test('branch/detached policies and dominant status bucket are deterministic', async () => { const session = createSessionForWorkspaceTests() as any + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: '/tmp/repo', + projectId: '/tmp/repo', + cwd: '/tmp/repo', + kind: 'local_checkout', + displayName: 'repo', + createdAt: '2026-03-01T12:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }), + ] session.listAgentPayloads = async () => [ makeAgent({ id: 'a1', @@ -177,19 +215,6 @@ describe('workspace aggregation', () => { pendingPermissions: 1, }), ] - session.buildProjectPlacement = async (cwd: string) => ({ - projectKey: cwd, - projectName: 'repo', - checkout: { - cwd, - isGit: true, - currentBranch: 'HEAD', - remoteUrl: 'https://github.com/acme/repo.git', - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }) - const result = await session.listFetchWorkspacesEntries({ type: 'fetch_workspaces_request', requestId: 'req-2', @@ -200,7 +225,7 @@ describe('workspace aggregation', () => { expect(result.entries[0]?.status).toBe('needs_input') }) - test('workspace update stream emits upsert and remove on lifecycle changes', async () => { + test('workspace update stream keeps persisted workspace visible after agents stop', async () => { const emitted: Array<{ type: string; payload: unknown }> = [] const logger = { child: () => logger, @@ -227,6 +252,24 @@ describe('workspace aggregation', () => { list: async () => [], get: async () => null, } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, createAgentMcpTransport: async () => { throw new Error('not used') }, @@ -246,6 +289,10 @@ describe('workspace aggregation', () => { { id: '/tmp/repo', projectId: '/tmp/repo', + projectDisplayName: 'repo', + projectRootPath: '/tmp/repo', + projectKind: 'non_git', + workspaceKind: 'directory', name: 'repo', status: 'running', activityAt: '2026-03-01T12:00:00.000Z', @@ -253,15 +300,37 @@ describe('workspace aggregation', () => { ] await session.emitWorkspaceUpdateForCwd('/tmp/repo') - session.listWorkspaceDescriptors = async () => [] + session.listWorkspaceDescriptors = async () => [ + { + id: '/tmp/repo', + projectId: '/tmp/repo', + projectDisplayName: 'repo', + projectRootPath: '/tmp/repo', + projectKind: 'non_git', + workspaceKind: 'directory', + name: 'repo', + status: 'done', + activityAt: null, + }, + ] await session.emitWorkspaceUpdateForCwd('/tmp/repo') const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') expect(workspaceUpdates).toHaveLength(2) expect((workspaceUpdates[0] as any).payload.kind).toBe('upsert') expect((workspaceUpdates[1] as any).payload).toEqual({ - kind: 'remove', - id: '/tmp/repo', + kind: 'upsert', + workspace: { + id: '/tmp/repo', + projectId: '/tmp/repo', + projectDisplayName: 'repo', + projectRootPath: '/tmp/repo', + projectKind: 'non_git', + workspaceKind: 'directory', + name: 'repo', + status: 'done', + activityAt: null, + }, }) }) @@ -289,4 +358,80 @@ describe('workspace aggregation', () => { expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(1, '/tmp/repo') expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(2, '/tmp/repo/sub') }) + + test('open_project_request registers a workspace before any agent exists', async () => { + const emitted: Array<{ type: string; payload: unknown }> = [] + const session = createSessionForWorkspaceTests() as any + const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>() + const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>() + + session.emit = (message: any) => emitted.push(message) + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null + session.projectRegistry.upsert = async (record: ReturnType<typeof createPersistedProjectRecord>) => { + projects.set(record.projectId, record) + } + session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null + session.workspaceRegistry.upsert = async ( + record: ReturnType<typeof createPersistedWorkspaceRecord> + ) => { + workspaces.set(record.workspaceId, record) + } + session.projectRegistry.list = async () => Array.from(projects.values()) + session.workspaceRegistry.list = async () => Array.from(workspaces.values()) + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: cwd, + projectName: 'repo', + checkout: { + cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }) + + await session.handleMessage({ + type: 'open_project_request', + cwd: '/tmp/repo', + requestId: 'req-open', + }) + + expect(workspaces.get('/tmp/repo')).toBeTruthy() + const response = emitted.find((message) => message.type === 'open_project_response') as any + expect(response?.payload.error).toBeNull() + expect(response?.payload.workspace?.id).toBe('/tmp/repo') + }) + + test('archive_workspace_request hides non-destructive workspace records', async () => { + const emitted: Array<{ type: string; payload: unknown }> = [] + const session = createSessionForWorkspaceTests() as any + const workspace = createPersistedWorkspaceRecord({ + workspaceId: '/tmp/repo', + projectId: '/tmp/repo', + cwd: '/tmp/repo', + kind: 'directory', + displayName: 'repo', + createdAt: '2026-03-01T12:00:00.000Z', + updatedAt: '2026-03-01T12:00:00.000Z', + }) + + session.emit = (message: any) => emitted.push(message) + session.workspaceRegistry.get = async () => workspace + session.workspaceRegistry.archive = async (_workspaceId: string, archivedAt: string) => { + workspace.archivedAt = archivedAt + } + session.workspaceRegistry.list = async () => [workspace] + session.projectRegistry.archive = async () => {} + + await session.handleMessage({ + type: 'archive_workspace_request', + workspaceId: '/tmp/repo', + requestId: 'req-archive', + }) + + expect(workspace.archivedAt).toBeTruthy() + const response = emitted.find((message) => message.type === 'archive_workspace_response') as any + expect(response?.payload.error).toBeNull() + }) }) diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index c25e8da96..a418726b6 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -8,6 +8,7 @@ import type { AgentStorage } from "./agent/agent-storage.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; +import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js"; import { type ServerInfoStatusPayload, type WSHelloMessage, @@ -70,6 +71,30 @@ type WebSocketServerConfig = { allowedHosts?: AllowedHostsConfig; }; +function createNoopProjectRegistry(): ProjectRegistry { + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; +} + +function createNoopWorkspaceRegistry(): WorkspaceRegistry { + return { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + }; +} + function toServerCapabilityState( params: { state: SpeechReadinessSnapshot["dictation"]; @@ -212,6 +237,8 @@ export class VoiceAssistantWebSocketServer { private readonly daemonVersion: string; private readonly agentManager: AgentManager; private readonly agentStorage: AgentStorage; + private readonly projectRegistry: ProjectRegistry; + private readonly workspaceRegistry: WorkspaceRegistry; private readonly downloadTokenStore: DownloadTokenStore; private readonly paseoHome: string; private readonly pushTokenStore: PushTokenStore; @@ -295,7 +322,9 @@ export class VoiceAssistantWebSocketServer { }, agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap, daemonVersion?: string, - onLifecycleIntent?: (intent: SessionLifecycleIntent) => void + onLifecycleIntent?: (intent: SessionLifecycleIntent) => void, + projectRegistry?: ProjectRegistry, + workspaceRegistry?: WorkspaceRegistry ) { this.logger = logger.child({ module: "websocket-server" }); this.serverId = serverId; @@ -305,6 +334,8 @@ export class VoiceAssistantWebSocketServer { this.daemonVersion = daemonVersion.trim(); this.agentManager = agentManager; this.agentStorage = agentStorage; + this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); + this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.createAgentMcpTransport = createAgentMcpTransport; @@ -597,6 +628,8 @@ export class VoiceAssistantWebSocketServer { paseoHome: this.paseoHome, agentManager: this.agentManager, agentStorage: this.agentStorage, + projectRegistry: this.projectRegistry, + workspaceRegistry: this.workspaceRegistry, createAgentMcpTransport: this.createAgentMcpTransport, stt: this.stt, tts: this.tts, diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts new file mode 100644 index 000000000..b34b670b0 --- /dev/null +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -0,0 +1,167 @@ +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync, rmSync } from 'node:fs' + +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { createTestLogger } from '../test-utils/test-logger.js' +import { AgentStorage } from './agent/agent-storage.js' +import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from './workspace-registry.js' +import { bootstrapWorkspaceRegistries } from './workspace-registry-bootstrap.js' + +describe('bootstrapWorkspaceRegistries', () => { + let tmpDir: string + let paseoHome: string + let agentStorage: AgentStorage + let projectRegistry: FileBackedProjectRegistry + let workspaceRegistry: FileBackedWorkspaceRegistry + const logger = createTestLogger() + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'workspace-bootstrap-')) + paseoHome = path.join(tmpDir, '.paseo') + agentStorage = new AgentStorage(path.join(paseoHome, 'agents'), logger) + projectRegistry = new FileBackedProjectRegistry( + path.join(paseoHome, 'projects', 'projects.json'), + logger + ) + workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(paseoHome, 'projects', 'workspaces.json'), + logger + ) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + test('materializes workspace registries from non-archived agent records', async () => { + await agentStorage.initialize() + await agentStorage.upsert({ + id: 'agent-1', + provider: 'codex', + cwd: '/tmp/non-git-project', + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-02T00:00:00.000Z', + lastActivityAt: '2026-03-02T00:00:00.000Z', + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: 'idle', + lastModeId: null, + config: null, + runtimeInfo: { provider: 'codex', sessionId: null }, + persistence: null, + archivedAt: null, + }) + await agentStorage.upsert({ + id: 'agent-2', + provider: 'codex', + cwd: '/tmp/non-git-project', + createdAt: '2026-03-01T01:00:00.000Z', + updatedAt: '2026-03-03T00:00:00.000Z', + lastActivityAt: '2026-03-03T00:00:00.000Z', + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: 'running', + lastModeId: null, + config: null, + runtimeInfo: { provider: 'codex', sessionId: null }, + persistence: null, + archivedAt: null, + }) + await agentStorage.upsert({ + id: 'agent-archived', + provider: 'codex', + cwd: '/tmp/archived-project', + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + lastActivityAt: '2026-03-01T00:00:00.000Z', + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: 'idle', + lastModeId: null, + config: null, + runtimeInfo: { provider: 'codex', sessionId: null }, + persistence: null, + archivedAt: '2026-03-02T00:00:00.000Z', + }) + + await bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + logger, + }) + + const workspaces = await workspaceRegistry.list() + expect(workspaces).toHaveLength(1) + expect(workspaces[0]?.workspaceId).toBe('/tmp/non-git-project') + expect(workspaces[0]?.createdAt).toBe('2026-03-01T00:00:00.000Z') + expect(workspaces[0]?.updatedAt).toBe('2026-03-03T00:00:00.000Z') + + const projects = await projectRegistry.list() + expect(projects).toHaveLength(1) + expect(projects[0]?.projectId).toBe('/tmp/non-git-project') + expect(projects[0]?.createdAt).toBe('2026-03-01T00:00:00.000Z') + expect(projects[0]?.updatedAt).toBe('2026-03-03T00:00:00.000Z') + }) + + test('does not rematerialize when registry files already exist', async () => { + await projectRegistry.initialize() + await workspaceRegistry.initialize() + await projectRegistry.upsert({ + projectId: '/tmp/existing', + rootPath: '/tmp/existing', + kind: 'non_git', + displayName: 'existing', + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + archivedAt: null, + }) + await workspaceRegistry.upsert({ + workspaceId: '/tmp/existing', + projectId: '/tmp/existing', + cwd: '/tmp/existing', + kind: 'directory', + displayName: 'existing', + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + archivedAt: null, + }) + + await agentStorage.initialize() + await agentStorage.upsert({ + id: 'agent-1', + provider: 'codex', + cwd: '/tmp/another-project', + createdAt: '2026-03-02T00:00:00.000Z', + updatedAt: '2026-03-02T00:00:00.000Z', + lastActivityAt: '2026-03-02T00:00:00.000Z', + lastUserMessageAt: null, + title: null, + labels: {}, + lastStatus: 'idle', + lastModeId: null, + config: null, + runtimeInfo: { provider: 'codex', sessionId: null }, + persistence: null, + archivedAt: null, + }) + + await bootstrapWorkspaceRegistries({ + paseoHome, + agentStorage, + projectRegistry, + workspaceRegistry, + logger, + }) + + expect(await projectRegistry.list()).toHaveLength(1) + expect(await workspaceRegistry.list()).toHaveLength(1) + expect((await workspaceRegistry.list())[0]?.workspaceId).toBe('/tmp/existing') + }) +}) diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts new file mode 100644 index 000000000..9e30ab5ae --- /dev/null +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -0,0 +1,142 @@ +import path from 'node:path' + +import type { Logger } from 'pino' + +import type { StoredAgentRecord } from './agent/agent-storage.js' +import type { AgentStorage } from './agent/agent-storage.js' +import { + buildProjectPlacementForCwd, + deriveProjectKind, + deriveProjectRootPath, + deriveWorkspaceDisplayName, + deriveWorkspaceKind, + normalizeWorkspaceId, +} from './workspace-registry-model.js' +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + type ProjectRegistry, + type WorkspaceRegistry, +} from './workspace-registry.js' + +function minIsoDate(left: string | null, right: string | null): string | null { + if (!left) { + return right + } + if (!right) { + return left + } + return Date.parse(left) <= Date.parse(right) ? left : right +} + +function maxIsoDate(left: string | null, right: string | null): string | null { + if (!left) { + return right + } + if (!right) { + return left + } + return Date.parse(left) >= Date.parse(right) ? left : right +} + +function resolveAgentCreatedAt(record: StoredAgentRecord): string { + return record.createdAt || record.updatedAt || new Date(0).toISOString() +} + +function resolveAgentUpdatedAt(record: StoredAgentRecord): string { + return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString() +} + +export async function bootstrapWorkspaceRegistries(options: { + paseoHome: string + agentStorage: AgentStorage + projectRegistry: ProjectRegistry + workspaceRegistry: WorkspaceRegistry + logger: Logger +}): Promise<void> { + const [projectsExists, workspacesExists] = await Promise.all([ + options.projectRegistry.existsOnDisk(), + options.workspaceRegistry.existsOnDisk(), + ]) + + await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]) + + if (projectsExists && workspacesExists) { + return + } + + const records = await options.agentStorage.list() + const activeRecords = records.filter((record) => !record.archivedAt) + const recordsByWorkspaceId = new Map<string, StoredAgentRecord[]>() + for (const record of activeRecords) { + const workspaceId = normalizeWorkspaceId(record.cwd) + const existing = recordsByWorkspaceId.get(workspaceId) ?? [] + existing.push(record) + recordsByWorkspaceId.set(workspaceId, existing) + } + + const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>() + + for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) { + const placement = await buildProjectPlacementForCwd({ + cwd: workspaceId, + paseoHome: options.paseoHome, + }) + + let workspaceCreatedAt: string | null = null + let workspaceUpdatedAt: string | null = null + for (const record of workspaceRecords) { + workspaceCreatedAt = minIsoDate(workspaceCreatedAt, resolveAgentCreatedAt(record)) + workspaceUpdatedAt = maxIsoDate(workspaceUpdatedAt, resolveAgentUpdatedAt(record)) + } + + const createdAt = workspaceCreatedAt ?? new Date().toISOString() + const updatedAt = workspaceUpdatedAt ?? createdAt + await options.workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId, + projectId: placement.projectKey, + cwd: workspaceId, + kind: deriveWorkspaceKind(placement.checkout), + displayName: deriveWorkspaceDisplayName({ + cwd: workspaceId, + checkout: placement.checkout, + }), + createdAt, + updatedAt, + }) + ) + + const existingProjectRange = projectRanges.get(placement.projectKey) ?? { + createdAt: null, + updatedAt: null, + } + existingProjectRange.createdAt = minIsoDate(existingProjectRange.createdAt, createdAt) + existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt) + projectRanges.set(placement.projectKey, existingProjectRange) + + await options.projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: placement.projectKey, + rootPath: deriveProjectRootPath({ + cwd: workspaceId, + checkout: placement.checkout, + }), + kind: deriveProjectKind(placement.checkout), + displayName: placement.projectName, + createdAt: existingProjectRange.createdAt ?? createdAt, + updatedAt: existingProjectRange.updatedAt ?? updatedAt, + }) + ) + } + + options.logger.info( + { + projectsFile: path.join(options.paseoHome, 'projects', 'projects.json'), + workspacesFile: path.join(options.paseoHome, 'projects', 'workspaces.json'), + materializedProjects: projectRanges.size, + materializedWorkspaces: recordsByWorkspaceId.size, + }, + 'Workspace registries bootstrapped from existing agent storage' + ) +} diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts new file mode 100644 index 000000000..692645c0b --- /dev/null +++ b/packages/server/src/server/workspace-registry-model.ts @@ -0,0 +1,192 @@ +import { resolve } from 'node:path' + +import { getCheckoutStatusLite } from '../utils/checkout-git.js' +import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from '../shared/messages.js' + +export type PersistedProjectKind = 'git' | 'non_git' +export type PersistedWorkspaceKind = 'local_checkout' | 'worktree' | 'directory' + +export function normalizeWorkspaceId(cwd: string): string { + const trimmed = cwd.trim() + if (!trimmed) { + return cwd + } + return resolve(trimmed) +} + +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 + } + + const cleanedHost = host.toLowerCase() + if (cleanedHost === 'github.com') { + return `remote:github.com/${cleanedPath}` + } + + return `remote:${cleanedHost}/${cleanedPath}` +} + +export function deriveProjectGroupingKey(options: { + cwd: string + remoteUrl: string | null + isPaseoOwnedWorktree: boolean + mainRepoRoot: string | null +}): string { + const remoteKey = deriveRemoteProjectKey(options.remoteUrl) + if (remoteKey) { + return remoteKey + } + + const mainRepoRoot = options.mainRepoRoot?.trim() + if (options.isPaseoOwnedWorktree && mainRepoRoot) { + return mainRepoRoot + } + + return options.cwd +} + +export function deriveProjectGroupingName(projectKey: string): string { + const githubRemotePrefix = 'remote:github.com/' + if (projectKey.startsWith(githubRemotePrefix)) { + return projectKey.slice(githubRemotePrefix.length) || projectKey + } + + const segments = projectKey.split(/[\\/]/).filter(Boolean) + return segments[segments.length - 1] || projectKey +} + +function deriveWorkspaceDirectoryName(cwd: string): string { + const normalized = cwd.replace(/\\/g, '/') + const segments = normalized.split('/').filter(Boolean) + return segments[segments.length - 1] ?? cwd +} + +export function deriveWorkspaceDisplayName(input: { + cwd: string + checkout: ProjectCheckoutLitePayload +}): string { + const branch = input.checkout.currentBranch?.trim() ?? null + if (branch && branch.toUpperCase() !== 'HEAD') { + return branch + } + return deriveWorkspaceDirectoryName(input.cwd) +} + +export function deriveProjectRootPath(input: { + cwd: string + checkout: ProjectCheckoutLitePayload +}): string { + if (input.checkout.isGit && input.checkout.isPaseoOwnedWorktree) { + return input.checkout.mainRepoRoot + } + return input.cwd +} + +export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind { + return checkout.isGit ? 'git' : 'non_git' +} + +export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): PersistedWorkspaceKind { + if (!checkout.isGit) { + return 'directory' + } + return checkout.isPaseoOwnedWorktree ? 'worktree' : 'local_checkout' +} + +export async function buildProjectPlacementForCwd(input: { + cwd: string + paseoHome: string +}): Promise<ProjectPlacementPayload> { + const normalizedCwd = normalizeWorkspaceId(input.cwd) + const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome }) + .then((status): ProjectCheckoutLitePayload => { + if (!status.isGit) { + return { + cwd: normalizedCwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + } + } + + if (status.isPaseoOwnedWorktree && status.mainRepoRoot) { + return { + cwd: normalizedCwd, + isGit: true, + currentBranch: status.currentBranch, + remoteUrl: status.remoteUrl, + isPaseoOwnedWorktree: true, + mainRepoRoot: status.mainRepoRoot, + } + } + + return { + cwd: normalizedCwd, + isGit: true, + currentBranch: status.currentBranch, + remoteUrl: status.remoteUrl, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + } + }) + .catch( + (): ProjectCheckoutLitePayload => ({ + cwd: normalizedCwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }) + ) + + const projectKey = deriveProjectGroupingKey({ + cwd: normalizedCwd, + remoteUrl: checkout.remoteUrl, + isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, + mainRepoRoot: checkout.mainRepoRoot, + }) + + return { + projectKey, + projectName: deriveProjectGroupingName(projectKey), + checkout, + } +} diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts new file mode 100644 index 000000000..cb599fe60 --- /dev/null +++ b/packages/server/src/server/workspace-registry.test.ts @@ -0,0 +1,106 @@ +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync, rmSync } from 'node:fs' + +import { beforeEach, afterEach, describe, expect, test } from 'vitest' + +import { createTestLogger } from '../test-utils/test-logger.js' +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, +} from './workspace-registry.js' + +describe('workspace registries', () => { + let tmpDir: string + let projectRegistry: FileBackedProjectRegistry + let workspaceRegistry: FileBackedWorkspaceRegistry + const logger = createTestLogger() + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'workspace-registry-')) + projectRegistry = new FileBackedProjectRegistry( + path.join(tmpDir, 'projects', 'projects.json'), + logger + ) + workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(tmpDir, 'projects', 'workspaces.json'), + logger + ) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + test('creates, updates, archives, deletes, and lists project records', async () => { + await projectRegistry.initialize() + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: 'remote:github.com/acme/repo', + rootPath: '/tmp/repo', + kind: 'git', + displayName: 'acme/repo', + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + }) + ) + + await projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: 'remote:github.com/acme/repo', + rootPath: '/tmp/repo', + kind: 'git', + displayName: 'acme/repo', + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-02T00:00:00.000Z', + }) + ) + await projectRegistry.archive('remote:github.com/acme/repo', '2026-03-03T00:00:00.000Z') + + const archived = await projectRegistry.get('remote:github.com/acme/repo') + expect(archived?.archivedAt).toBe('2026-03-03T00:00:00.000Z') + expect((await projectRegistry.list())).toHaveLength(1) + + await projectRegistry.remove('remote:github.com/acme/repo') + expect(await projectRegistry.get('remote:github.com/acme/repo')).toBeNull() + expect(await projectRegistry.list()).toEqual([]) + }) + + test('creates, updates, archives, deletes, and lists workspace records', async () => { + await workspaceRegistry.initialize() + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: '/tmp/repo', + projectId: 'remote:github.com/acme/repo', + cwd: '/tmp/repo', + kind: 'local_checkout', + displayName: 'main', + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + }) + ) + + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: '/tmp/repo', + projectId: 'remote:github.com/acme/repo', + cwd: '/tmp/repo', + kind: 'local_checkout', + displayName: 'feature/workspace', + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-02T00:00:00.000Z', + }) + ) + await workspaceRegistry.archive('/tmp/repo', '2026-03-03T00:00:00.000Z') + + const archived = await workspaceRegistry.get('/tmp/repo') + expect(archived?.displayName).toBe('feature/workspace') + expect(archived?.archivedAt).toBe('2026-03-03T00:00:00.000Z') + + await workspaceRegistry.remove('/tmp/repo') + expect(await workspaceRegistry.get('/tmp/repo')).toBeNull() + expect(await workspaceRegistry.list()).toEqual([]) + }) +}) diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts new file mode 100644 index 000000000..99af40927 --- /dev/null +++ b/packages/server/src/server/workspace-registry.ts @@ -0,0 +1,221 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' + +import type { Logger } from 'pino' +import { z } from 'zod' + +import type { + PersistedProjectKind, + PersistedWorkspaceKind, +} from './workspace-registry-model.js' + +const PersistedProjectRecordSchema = z.object({ + projectId: z.string(), + rootPath: z.string(), + kind: z.enum(['git', 'non_git']), + displayName: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().nullable(), +}) + +const PersistedWorkspaceRecordSchema = z.object({ + workspaceId: z.string(), + projectId: z.string(), + cwd: z.string(), + kind: z.enum(['local_checkout', 'worktree', 'directory']), + displayName: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + archivedAt: z.string().nullable(), +}) + +export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema> +export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema> + +export interface ProjectRegistry { + initialize(): Promise<void> + existsOnDisk(): Promise<boolean> + list(): Promise<PersistedProjectRecord[]> + get(projectId: string): Promise<PersistedProjectRecord | null> + upsert(record: PersistedProjectRecord): Promise<void> + archive(projectId: string, archivedAt: string): Promise<void> + remove(projectId: string): Promise<void> +} + +export interface WorkspaceRegistry { + initialize(): Promise<void> + existsOnDisk(): Promise<boolean> + list(): Promise<PersistedWorkspaceRecord[]> + get(workspaceId: string): Promise<PersistedWorkspaceRecord | null> + upsert(record: PersistedWorkspaceRecord): Promise<void> + archive(workspaceId: string, archivedAt: string): Promise<void> + remove(workspaceId: string): Promise<void> +} + +type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord + +class FileBackedRegistry<TRecord extends RegistryRecord> { + private readonly filePath: string + private readonly logger: Logger + private readonly schema: z.ZodSchema<TRecord> + private readonly getId: (record: TRecord) => string + private loaded = false + private readonly cache = new Map<string, TRecord>() + + constructor(options: { + filePath: string + logger: Logger + schema: z.ZodSchema<TRecord> + getId: (record: TRecord) => string + component: string + }) { + this.filePath = options.filePath + this.schema = options.schema + this.getId = options.getId + this.logger = options.logger.child({ module: 'workspace-registry', component: options.component }) + } + + async initialize(): Promise<void> { + await this.load() + } + + async existsOnDisk(): Promise<boolean> { + try { + await fs.access(this.filePath) + return true + } catch { + return false + } + } + + async list(): Promise<TRecord[]> { + await this.load() + return Array.from(this.cache.values()) + } + + async get(id: string): Promise<TRecord | null> { + await this.load() + return this.cache.get(id) ?? null + } + + async upsert(record: TRecord): Promise<void> { + await this.load() + const parsed = this.schema.parse(record) + this.cache.set(this.getId(parsed), parsed) + await this.persist() + } + + async archive(id: string, archivedAt: string): Promise<void> { + await this.load() + const existing = this.cache.get(id) + if (!existing) { + return + } + const next = this.schema.parse({ + ...existing, + updatedAt: archivedAt, + archivedAt, + }) + this.cache.set(id, next) + await this.persist() + } + + async remove(id: string): Promise<void> { + await this.load() + if (!this.cache.delete(id)) { + return + } + await this.persist() + } + + private async load(): Promise<void> { + if (this.loaded) { + return + } + + this.cache.clear() + try { + const raw = await fs.readFile(this.filePath, 'utf8') + const parsed = z.array(this.schema).parse(JSON.parse(raw)) + for (const record of parsed) { + this.cache.set(this.getId(record), record) + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code !== 'ENOENT') { + this.logger.error({ err: error, filePath: this.filePath }, 'Failed to load registry file') + } + } + this.loaded = true + } + + private async persist(): Promise<void> { + const records = Array.from(this.cache.values()) + await fs.mkdir(path.dirname(this.filePath), { recursive: true }) + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp` + await fs.writeFile(tempPath, JSON.stringify(records, null, 2), 'utf8') + await fs.rename(tempPath, this.filePath) + } +} + +export class FileBackedProjectRegistry + extends FileBackedRegistry<PersistedProjectRecord> + implements ProjectRegistry +{ + constructor(filePath: string, logger: Logger) { + super({ + filePath, + logger, + schema: PersistedProjectRecordSchema, + getId: (record) => record.projectId, + component: 'projects', + }) + } +} + +export class FileBackedWorkspaceRegistry + extends FileBackedRegistry<PersistedWorkspaceRecord> + implements WorkspaceRegistry +{ + constructor(filePath: string, logger: Logger) { + super({ + filePath, + logger, + schema: PersistedWorkspaceRecordSchema, + getId: (record) => record.workspaceId, + component: 'workspaces', + }) + } +} + +export function createPersistedProjectRecord(input: { + projectId: string + rootPath: string + kind: PersistedProjectKind + displayName: string + createdAt: string + updatedAt: string + archivedAt?: string | null +}): PersistedProjectRecord { + return PersistedProjectRecordSchema.parse({ + ...input, + archivedAt: input.archivedAt ?? null, + }) +} + +export function createPersistedWorkspaceRecord(input: { + workspaceId: string + projectId: string + cwd: string + kind: PersistedWorkspaceKind + displayName: string + createdAt: string + updatedAt: string + archivedAt?: string | null +}): PersistedWorkspaceRecord { + return PersistedWorkspaceRecordSchema.parse({ + ...input, + archivedAt: input.archivedAt ?? null, + }) +} diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index f86bc9d9e..38a281e09 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -910,6 +910,18 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({ requestId: z.string(), }) +export const OpenProjectRequestSchema = z.object({ + type: z.literal('open_project_request'), + cwd: z.string(), + requestId: z.string(), +}) + +export const ArchiveWorkspaceRequestSchema = z.object({ + type: z.literal('archive_workspace_request'), + workspaceId: z.string(), + requestId: z.string(), +}) + // Highlighted diff token schema // Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter const HighlightTokenSchema = z.object({ @@ -1148,6 +1160,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [ DirectorySuggestionsRequestSchema, PaseoWorktreeListRequestSchema, PaseoWorktreeArchiveRequestSchema, + OpenProjectRequestSchema, + ArchiveWorkspaceRequestSchema, FileExplorerRequestSchema, ProjectIconRequestSchema, FileDownloadTokenRequestSchema, @@ -1469,6 +1483,10 @@ export const ProjectPlacementPayloadSchema = z.object({ export const WorkspaceDescriptorPayloadSchema = z.object({ id: z.string(), projectId: z.string(), + projectDisplayName: z.string(), + projectRootPath: z.string(), + projectKind: z.enum(['git', 'non_git']), + workspaceKind: z.enum(['local_checkout', 'worktree', 'directory']), name: z.string(), status: WorkspaceStateBucketSchema, activityAt: z.string().nullable(), @@ -1564,6 +1582,25 @@ export const WorkspaceUpdateMessageSchema = z.object({ ]), }) +export const OpenProjectResponseMessageSchema = z.object({ + type: z.literal('open_project_response'), + payload: z.object({ + requestId: z.string(), + workspace: WorkspaceDescriptorPayloadSchema.nullable(), + error: z.string().nullable(), + }), +}) + +export const ArchiveWorkspaceResponseMessageSchema = z.object({ + type: z.literal('archive_workspace_response'), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + archivedAt: z.string().nullable(), + error: z.string().nullable(), + }), +}) + export const FetchAgentResponseMessageSchema = z.object({ type: z.literal('fetch_agent_response'), payload: z.object({ @@ -2133,6 +2170,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [ AgentStatusMessageSchema, FetchAgentsResponseMessageSchema, FetchWorkspacesResponseMessageSchema, + OpenProjectResponseMessageSchema, + ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, SendAgentMessageResponseMessageSchema, @@ -2202,6 +2241,8 @@ export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema> export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema> export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema> export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema> +export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema> +export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema> export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema> export type FetchAgentTimelineResponseMessage = z.infer< typeof FetchAgentTimelineResponseMessageSchema @@ -2278,6 +2319,8 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema> export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema> export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema> +export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema> +export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema> export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema> export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema> export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema> diff --git a/packages/server/src/shared/messages.workspaces.test.ts b/packages/server/src/shared/messages.workspaces.test.ts index dbbb136f8..17d98b342 100644 --- a/packages/server/src/shared/messages.workspaces.test.ts +++ b/packages/server/src/shared/messages.workspaces.test.ts @@ -22,6 +22,16 @@ describe('workspace message schemas', () => { expect(parsed.type).toBe('fetch_workspaces_request') }) + test('parses open_project_request', () => { + const parsed = SessionInboundMessageSchema.parse({ + type: 'open_project_request', + cwd: '/tmp/repo', + requestId: 'req-open', + }) + + expect(parsed.type).toBe('open_project_request') + }) + test('rejects invalid workspace update payload', () => { const result = SessionOutboundMessageSchema.safeParse({ type: 'workspace_update', @@ -30,6 +40,10 @@ describe('workspace message schemas', () => { workspace: { id: '/repo', projectId: '/repo', + projectDisplayName: 'repo', + projectRootPath: '/repo', + projectKind: 'non_git', + workspaceKind: 'directory', name: '', status: 'not-a-bucket', activityAt: null,