From 080fb7464245e27e75625103e5d1a83f948b9546 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Mar 2026 18:22:11 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20projects=20=E2=86=92=20workspaces=20?= =?UTF-8?q?=E2=86=92=20tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EXECUTION_PLAN_PASEO.md | 138 ++ PLAN_PROJECTS_WORKSPACES_TABS.md | 448 ++++++ packages/app/src/app/_layout.tsx | 18 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 100 +- .../app/src/app/h/[serverId]/agent/index.tsx | 48 +- packages/app/src/app/h/[serverId]/index.tsx | 36 +- packages/app/src/app/h/[serverId]/new.tsx | 9 + .../[workspaceId]/agent/[agentId].tsx | 20 + .../workspace/[workspaceId]/index.tsx | 14 + .../[workspaceId]/terminal/[terminalId].tsx | 21 + packages/app/src/app/index.tsx | 3 +- packages/app/src/components/agent-list.tsx | 13 +- .../app/src/components/agent-stream-view.tsx | 15 +- .../app/src/components/explorer-sidebar.tsx | 63 +- .../app/src/components/file-explorer-pane.tsx | 133 +- packages/app/src/components/git-diff-pane.tsx | 8 +- packages/app/src/components/left-sidebar.tsx | 510 +++---- .../app/src/components/sidebar-agent-list.tsx | 1354 ++++++----------- packages/app/src/components/terminal-pane.tsx | 272 ++-- packages/app/src/contexts/session-context.tsx | 270 +--- .../src/hooks/use-file-explorer-actions.ts | 132 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 7 +- .../src/hooks/use-sidebar-agents-list.test.ts | 212 +-- .../app/src/hooks/use-sidebar-agents-list.ts | 862 +++++++---- .../keyboard-shortcut-routing.test.ts | 24 +- .../src/keyboard/keyboard-shortcut-routing.ts | 25 +- .../src/screens/agent/agent-ready-screen.tsx | 202 +-- .../src/screens/agent/draft-agent-screen.tsx | 8 +- .../screens/agent/legacy-agent-id-screen.tsx | 206 --- .../screens/workspace/workspace-screen.tsx | 1013 ++++++++++++ packages/app/src/stores/download-store.ts | 15 +- .../app/src/stores/explorer-tab-memory.ts | 4 +- packages/app/src/stores/panel-store.test.ts | 6 +- packages/app/src/stores/panel-store.ts | 6 +- .../app/src/stores/sidebar-order-store.ts | 120 +- .../app/src/stores/workspace-tabs-store.ts | 136 ++ packages/app/src/utils/host-routes.test.ts | 51 +- packages/app/src/utils/host-routes.ts | 151 +- .../app/src/utils/new-agent-routing.test.ts | 8 +- packages/app/src/utils/new-agent-routing.ts | 5 + .../src/utils/notification-routing.test.ts | 2 +- .../app/src/utils/notification-routing.ts | 4 +- packages/server/src/server/session.ts | 16 +- 43 files changed, 4054 insertions(+), 2654 deletions(-) create mode 100644 EXECUTION_PLAN_PASEO.md create mode 100644 PLAN_PROJECTS_WORKSPACES_TABS.md create mode 100644 packages/app/src/app/h/[serverId]/new.tsx create mode 100644 packages/app/src/app/h/[serverId]/workspace/[workspaceId]/agent/[agentId].tsx create mode 100644 packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx create mode 100644 packages/app/src/app/h/[serverId]/workspace/[workspaceId]/terminal/[terminalId].tsx delete mode 100644 packages/app/src/screens/agent/legacy-agent-id-screen.tsx create mode 100644 packages/app/src/screens/workspace/workspace-screen.tsx create mode 100644 packages/app/src/stores/workspace-tabs-store.ts diff --git a/EXECUTION_PLAN_PASEO.md b/EXECUTION_PLAN_PASEO.md new file mode 100644 index 000000000..19c5a4b2a --- /dev/null +++ b/EXECUTION_PLAN_PASEO.md @@ -0,0 +1,138 @@ +# 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/PLAN_PROJECTS_WORKSPACES_TABS.md b/PLAN_PROJECTS_WORKSPACES_TABS.md new file mode 100644 index 000000000..61a0db056 --- /dev/null +++ b/PLAN_PROJECTS_WORKSPACES_TABS.md @@ -0,0 +1,448 @@ +# Projects → Workspaces → Tabs (Full-Stack Plan) + +This plan replaces the current “agents list + single chat view” IA with: + +- **Projects** (grouped by a stable `projectKey`) +- **Workspaces** per project (today: local Paseo worktrees + main checkout; future: cloud VM) +- A **workspace main view** that is a **tabbed panel** where each tab is either an **agent** chat or a **terminal** + +No legacy UI is kept: we redirect and fully rework the current routes and RPCs that don’t make sense anymore. + +--- + +## TL;DR + +- **Sidebar** becomes **Projects → Workspaces**; still supports drag reorder (projects + workspaces) persisted on-device. +- **Project key**: **remote when available**; otherwise **local repo root / workspace root** (CWD fallback). +- **Workspaces**: show **main checkout + all Paseo worktrees**, including empty workspaces. +- **Main view**: opening a workspace shows a **tab bar** with **all agents in that workspace**; horizontally scrollable. +- **Tabs** are a discriminated union: `agent` or `terminal` (terminals are not special). +- **Right sidebar** stays **Changes + Files**, scoped to the current workspace; **terminals removed** from the right sidebar. +- **File Explorer RPC**: replace agent-scoped explorer/download-token RPC with **workspace-scoped** versions. +- **Agent data** is loaded once at daemon startup and kept queryable in-memory (no expensive directory rescans at runtime). +- **Splits**: not implemented now, but abstractions should not block future VSCode-style splits. + +--- + +## Definitions & IDs + +### Project + +A **Project** is a grouping container keyed by `projectKey`. + +**`projectKey` derivation (must match user decisions):** + +1. If git remote is available: use normalized remote key (e.g. `remote:github.com/org/repo`). +2. Else (git with no remote OR not git): use the **workspace/repo root path** (local key). + +Notes: +- For Paseo-owned worktrees, prefer grouping via the **shared repo root** when available (so multiple workspaces still group under one project even without a remote). +- For plain directories, the root path is the directory itself. + +### Workspace + +A **Workspace** is an environment you can “open” and run agents/terminals within. + +Today: +- **Local main checkout** (the normal repo root) is a workspace and **cannot be archived**. +- **Local Paseo worktrees** are workspaces (including empty ones). + +Future: +- Workspace could point to a **cloud VM** or other remote compute target. + +Workspace identity: +- `workspaceId` should be stable and unique within a server. +- For local: derive from canonical absolute `workspaceRoot` (and/or from the worktree metadata id when we add one). + +### Tab + +Tabs are the only top-level “things” in the workspace main view. + +**Discriminated union (locked):** + +```ts +type WorkspaceTab = + | { kind: "agent"; tabId: string; agentId: string } + | { kind: "terminal"; tabId: string; terminalId: string } +``` + +Terminals are not special: they are just `kind: "terminal"` tabs. + +### Panel (future splits) + +We do **not** implement splits now. We still design tab/panel state so it can evolve into a layout model: + +```ts +type PanelLayout = + | { kind: "single"; panelId: string } + // later: | { kind: "split"; direction: "row"|"column"; a: PanelLayout; b: PanelLayout; sizes: number[] } +``` + +For this milestone, `PanelLayout` is always `{ kind: "single" }`. + +--- + +## UX (Agreed) + +### Sidebar (Left) + +- Shows **Projects** (project icon + name + status dot). +- Each project expands to show **Workspaces**. +- Workspaces list includes: + - **Main checkout workspace** (always present, not archivable) + - **All Paseo worktrees** for that project (show all, including empty workspaces) +- Workspace row shows: + - **Branch name** + - **Created at** + - **No path** +- **Drag reorder**: + - Projects reorder globally + - Workspaces reorder within a project + - Persisted on-device +- **No agents** are shown in the sidebar (agents live in tabs + in the All Agents list). + +### Main View (Workspace) + +- Opening a workspace shows a workspace-scoped screen. +- Top area contains a **tab bar** with all agent tabs in that workspace: + - horizontally scrollable + - switching tabs focuses the selected agent/terminal +- **Remember last focus**: + - When returning to a workspace, restore the last focused tab (agent or terminal). + - When opening a workspace for the first time: pick the most recent agent, else open the draft agent flow. + +### Tabs + Icons + +- Each tab type provides its own icon. + - Terminal tabs: terminal icon. + - Agent tabs: status icon (running/idle/needs-attention/error) or provider icon if needed. + +### Right Sidebar (Explorer Sidebar) + +- Remains **Changes + Files** only. +- Scope is the **workspace you are looking at**. +- Selection of agent/terminal tab does **not** change the right sidebar scope. +- Terminals are removed from the right sidebar entirely. +- Gestures/overlay behavior are unchanged (mobile). + +### Mobile-specific + +- Keep existing overlay sidebars and gestures unchanged. +- Use a **tab switcher** pattern (bottom sheet / switcher) for tab navigation. +- Header is simplified: + - shows only workspace name (branch name) + - has space for a **plus** button +- Plus button: + - opens the **draft agent flow**, pre-scoped to the current workspace. + +--- + +## Data Model (Client) + +### On-device persisted state + +Persist (per `serverId`): +- `projectOrder: projectKey[]` +- `workspaceOrderByProject: Record` +- `lastOpenedWorkspaceByProject: Record` +- `lastFocusedTabByWorkspace: Record` +- `openTabsByWorkspace: Record` (optional; can be derived initially from agents/terminals list but should be persistable) + +Default ordering: +- If no persisted order: + - Projects sorted by `createdAt/firstSeenAt` (or by name as fallback) + - Workspaces sorted by **createdAt** (locked decision) + +### Derived view models + +Compute: +- `projects[]` from workspace + checkout metadata +- `projectStatusDot` aggregated from agent statuses in all workspaces in that project +- `workspaceStatusDot` aggregated from agent statuses in that workspace + +--- + +## Data Model (Server) + +### Agents: load once, query often + +Current pain: +- Hydrating agents by scanning directories is expensive. + +Target behavior: +- On daemon startup, perform a single load/scan and build an in-memory index: + - by `agentId` + - by `workspaceRoot` + - by `projectKey` + - by status (running, attention, errored, etc.) +- No periodic rescans at runtime. +- When an agent is created/updated/removed, update the in-memory index incrementally. +- This aligns with the PID lock: we don’t support concurrent disk mutation anyway. +- Future: replace with local SQL DB, but not in this milestone. + +### Workspaces (local) + +Source of truth: +- Main checkout: derived from repo root / current connection cwd policy. +- Paseo worktrees: `paseo_worktree_list_*` plus metadata. + +Worktree metadata needs `createdAt`: +- Add `createdAt` to Paseo worktree metadata (new version). +- Backfill for existing worktrees using filesystem stat of metadata file or worktree directory. + +--- + +## RPC / Protocol Changes (Server ↔ App) + +### Replace: File Explorer request (agent-scoped → workspace-scoped) + +**Current (to remove):** +- `file_explorer_request` requires `agentId`. + +**New (replacement; same message type name or updated schema, but old behavior removed):** +- `file_explorer_request` keyed by `workspaceId` (or `workspaceRoot`) instead of `agentId`. +- Requests can succeed even if there are **no agents** in the workspace (empty workspace is valid). + +Rules: +- All explorer operations are rooted at `workspaceRoot`. +- Path traversal is prevented (no `..`, enforce prefix). + +### Replace: File download token request (agent-scoped → workspace-scoped) + +**Current (to remove):** +- `file_download_token_request` requires `agentId`. + +**New:** +- `file_download_token_request` requires `workspaceId` (or `workspaceRoot`) and `path`. +- Token resolution is workspace-rooted with the same traversal protections. + +### Worktree list RPC: include `createdAt` + +Update `paseo_worktree_list_response` to include: +- `createdAt` (ISO string) +- `workspaceId` (optional if client can derive; preferred to include for stability) + +Ensure the list includes: +- all Paseo worktrees for the requested repo/project +- enough info to render workspace row: branch name + createdAt + +### Checkout lite payload: ensure it contains enough for project grouping + +For workspace/project grouping on the client, ensure we have: +- `isGit` +- `remoteUrl` (nullable) +- `currentBranch` +- `isPaseoOwnedWorktree` +- `mainRepoRoot` (when worktree-owned) +- (optional) `repoRoot` for non-worktree git repos + +--- + +## App Changes (packages/app) + +### Routes + +Replace/redirect: +- `/h/:serverId` should no longer redirect to “draft agent”; it should land on: + - last opened workspace, or + - a workspace picker (the new sidebar is the primary picker) +- `/h/:serverId/agent` and `/h/:serverId/agent/:agentId` are replaced with: + - `/h/:serverId/workspace/:workspaceId` (main workspace screen) + - `/h/:serverId/workspace/:workspaceId/draft-agent` (draft flow, pre-scoped) + +No legacy route behavior remains; old routes redirect. + +### Left Sidebar rewrite + +Replace the current agent list sidebar with: +- Project list (icon + name + status dot) +- Nested workspace list (branch name + createdAt) +- Drag reorder persisted on-device + +### Workspace screen + +New top-level screen composition: +- Header: workspace name (branch) + plus button +- Tab strip (horizontal) +- Active tab content (agent chat or terminal UI) +- Right sidebar: changes + file explorer (workspace-scoped) + +### Tabs system + +Implement `WorkspaceTab` union: +- `agent` tabs map to existing agent chat component (but now hosted within the workspace screen). +- `terminal` tabs map to existing terminal component previously shown in the right sidebar. + +Remember focus: +- Persist `lastFocusedTabByWorkspace`. + +### Terminal migration + +- Remove terminal UI from right sidebar. +- Expose terminals through tabs: + - create terminal -> adds a `terminal` tab + - show terminal output in the main area + +### File explorer state migration + +Update file explorer client state/actions: +- Key state by `workspaceId` (not `agentId`). +- Update all call sites to use the new RPC parameters. + +--- + +## Status Dots (Sidebar) + +### Agent → Workspace → Project aggregation + +Define a simple severity ordering, e.g.: + +1. Error +2. Needs attention +3. Running +4. Idle / none + +Compute: +- workspace status = max severity across agents in that workspace +- project status = max severity across all workspaces in that project + +No agents are displayed in the sidebar; only aggregated signal. + +--- + +## User Flows (Agreed) + +### Open app → open a workspace + +1. User opens app. +2. Left sidebar shows Projects. +3. User selects a Project. +4. User selects a Workspace (main checkout or a worktree). +5. Workspace screen opens: + - tab strip shows all agents in that workspace + - restores last focused tab if available + - otherwise picks most recent agent + - otherwise opens draft agent flow pre-scoped to the workspace + +### Create a new agent in a workspace + +1. User is inside a workspace. +2. User taps plus in header. +3. Draft agent flow opens, pre-scoped to that workspace root. +4. On create: + - new agent appears as a new `agent` tab in the workspace + - it becomes focused + +### Open a terminal + +1. User taps plus (or a “new tab” affordance). +2. Selects “Terminal”. +3. A `terminal` tab is created and focused. +4. Terminal is fully usable inside the tab panel. + +### Switch tabs (mobile) + +1. User opens tab switcher. +2. Selects an agent/terminal tab. +3. Workspace focuses that tab and persists focus. + +### Reorder projects and workspaces + +1. User drags a project to reorder. +2. Order persists on-device and survives app restart. +3. User expands a project and drags a workspace row to reorder within the project. +4. Order persists on-device and survives app restart. + +--- + +## Acceptance Criteria + +### Sidebar / Navigation + +- Projects are grouped by `projectKey`: + - remote when available + - otherwise local repo/workspace root key +- Sidebar shows **project icon** for each project. +- Each project lists workspaces (main checkout + all Paseo worktrees), including empty workspaces. +- Workspace rows show **branch name + createdAt**, and **do not show path**. +- Projects and workspaces can be drag-reordered; order is persisted on-device. +- Sidebar does not list agents anywhere. + +### Workspace main view + +- Opening a workspace shows a tabbed main view (not a single chat screen). +- Tab bar contains all agents in the workspace; horizontally scrollable. +- Last focused tab is restored when returning to a workspace. +- Header shows workspace name and a plus button. +- Plus opens draft agent flow pre-scoped to workspace. +- Mobile uses a tab switcher; sidebars/gestures unchanged. + +### Tabs / Terminals + +- Tabs are a discriminated union: `agent` or `terminal`. +- Terminals are accessible only as tabs (no terminal section in the right sidebar). +- Each tab type can render a custom icon; agent tabs can render status. + +### Explorer / Changes / Files + +- Right sidebar contains only Changes + Files, scoped to the opened workspace. +- File explorer and download token requests are workspace-scoped (no agentId in protocol). +- File explorer works for empty workspaces (no agents yet). + +### Migration / No legacy + +- Old agent-centric routes redirect to the new workspace routes. +- Old agent-scoped file explorer RPC is removed/replaced (no legacy behavior left behind). + +--- + +## Implementation Phases (Suggested) + +### Phase 0 — Scaffolding + +- Add shared types: `Project`, `Workspace`, `WorkspaceTab`. +- Add on-device storage keys and migration scaffolding (no UI yet). + +### Phase 1 — Server protocol + data + +- Replace file explorer + download token RPCs to be workspace-scoped. +- Ensure checkout-lite has enough fields for grouping. +- Add worktree `createdAt` and include it in worktree list response. +- Ensure “main checkout workspace” can be represented as a workspace. +- Ensure agent index loads once at startup and is queryable without rescans. + +### Phase 2 — App navigation rewrite + +- Introduce workspace routes and redirect old routes. +- Add new workspace screen layout (header + tabs + explorer sidebar). + +### Phase 3 — Sidebar rewrite + +- Implement Projects → Workspaces sidebar UI. +- Implement project icon wiring. +- Implement drag reorder + persisted ordering. +- Implement project/workspace status dots from agent status aggregation. + +### Phase 4 — Tabs + terminals migration + +- Implement tab state + last focused persistence. +- Move terminals out of right sidebar and into tabs. +- Implement mobile tab switcher. + +### Phase 5 — Polish + verification + +- Verify flows end-to-end (open workspace, tabs, explorer, plus flows). +- Confirm no legacy route/UI remains. +- Typecheck and targeted runtime smoke tests. + +--- + +## Verification Checklist + +- `npm run typecheck` passes after each phase. +- Explorer sidebar still behaves the same (gestures/overlay). +- No references remain to “file explorer by agentId” or “download token by agentId”. +- Terminals are not rendered in the right sidebar. +- Workspace list includes empty worktrees and main checkout. +- Reorder persistence works and survives app restart. + diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index b337277a9..dca817235 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -45,8 +45,9 @@ import { } from "@/utils/os-notifications"; import { buildNotificationRoute } from "@/utils/notification-routing"; import { - buildHostAgentDraftRoute, + buildHostDraftRoute, parseHostAgentRouteFromPathname, + parseHostWorkspaceAgentRouteFromPathname, } from "@/utils/host-routes"; import { getTauri } from "@/utils/tauri"; import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics"; @@ -350,7 +351,7 @@ function OfferLinkListener({ if (cancelled) return; const serverId = (profile as any)?.serverId; if (typeof serverId !== "string" || !serverId) return; - router.replace(buildHostAgentDraftRoute(serverId) as any); + router.replace(buildHostDraftRoute(serverId) as any); }) .catch((error) => { if (cancelled) return; @@ -380,7 +381,9 @@ function AppWithSidebar({ children }: { children: ReactNode }) { // Parse selectedAgentKey directly from pathname // useLocalSearchParams doesn't update when navigating between same-pattern routes const selectedAgentKey = useMemo(() => { - const match = parseHostAgentRouteFromPathname(pathname); + const match = + parseHostWorkspaceAgentRouteFromPathname(pathname) ?? + parseHostAgentRouteFromPathname(pathname); return match ? `${match.serverId}:${match.agentId}` : undefined; }, [pathname]); @@ -465,6 +468,15 @@ export default function RootLayout() { > + + + + (); + const redirectedRef = useRef(false); + const serverId = typeof params.serverId === "string" ? params.serverId : ""; + const agentId = typeof params.agentId === "string" ? params.agentId : ""; + const { client, isConnected } = useHostRuntimeSession(serverId); + const agentCwd = useSessionStore((state) => { + if (!serverId || !agentId) { + return null; + } + return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null; + }); - return ( - - ); + useEffect(() => { + if (redirectedRef.current) { + return; + } + if (!serverId || !agentId) { + redirectedRef.current = true; + router.replace("/" as any); + return; + } + + const normalizedCwd = agentCwd?.trim(); + if (normalizedCwd) { + redirectedRef.current = true; + router.replace( + buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any + ); + } + }, [agentCwd, agentId, router, serverId]); + + useEffect(() => { + if (redirectedRef.current) { + return; + } + if (!serverId || !agentId) { + return; + } + if (agentCwd?.trim()) { + return; + } + if (!client || !isConnected) { + redirectedRef.current = true; + router.replace(buildHostDraftRoute(serverId) as any); + } + }, [agentCwd, agentId, client, isConnected, router, serverId]); + + useEffect(() => { + if (redirectedRef.current) { + return; + } + if (!serverId || !agentId || !client || !isConnected) { + return; + } + + let cancelled = false; + void client + .fetchAgent(agentId) + .then((result) => { + if (cancelled || redirectedRef.current) { + return; + } + const cwd = result?.agent?.cwd?.trim(); + redirectedRef.current = true; + if (cwd) { + router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any); + return; + } + router.replace(buildHostDraftRoute(serverId) as any); + }) + .catch(() => { + if (cancelled || redirectedRef.current) { + return; + } + redirectedRef.current = true; + router.replace(buildHostDraftRoute(serverId) as any); + }); + + return () => { + cancelled = true; + }; + }, [agentId, client, isConnected, router, serverId]); + + return null; } - diff --git a/packages/app/src/app/h/[serverId]/agent/index.tsx b/packages/app/src/app/h/[serverId]/agent/index.tsx index b1aaa55ac..9f41cd645 100644 --- a/packages/app/src/app/h/[serverId]/agent/index.tsx +++ b/packages/app/src/app/h/[serverId]/agent/index.tsx @@ -1,10 +1,48 @@ -import { useLocalSearchParams } from "expo-router"; -import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; +import { useEffect, useMemo } from "react"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { buildHostDraftRoute } from "@/utils/host-routes"; export default function HostDraftAgentRoute() { - const params = useLocalSearchParams<{ serverId?: string }>(); + const router = useRouter(); + const params = useLocalSearchParams() as Record< + string, + string | string[] | undefined + >; const serverId = typeof params.serverId === "string" ? params.serverId : ""; - return ; -} + const redirectRoute = useMemo(() => { + if (!serverId) { + return "/"; + } + const next = buildHostDraftRoute(serverId); + const queryParts: string[] = []; + for (const key in params) { + const value = params[key]; + if (key === "serverId") { + continue; + } + if (typeof value === "string") { + queryParts.push( + `${encodeURIComponent(key)}=${encodeURIComponent(value)}` + ); + continue; + } + if (Array.isArray(value)) { + for (const entry of value) { + queryParts.push( + `${encodeURIComponent(key)}=${encodeURIComponent(entry)}` + ); + } + } + } + const queryString = queryParts.join("&"); + return queryString.length > 0 ? `${next}?${queryString}` : next; + }, [params, serverId]); + + useEffect(() => { + router.replace(redirectRoute as any); + }, [redirectRoute, router]); + + return null; +} diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 1de679d5e..bf4846b09 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -1,19 +1,47 @@ import { useEffect } from "react"; import { useLocalSearchParams, useRouter } from "expo-router"; -import { buildHostAgentDraftRoute } from "@/utils/host-routes"; +import { useSessionStore } from "@/stores/session-store"; +import { + buildHostDraftRoute, + buildHostWorkspaceAgentRoute, +} from "@/utils/host-routes"; export default function HostIndexRoute() { const router = useRouter(); const params = useLocalSearchParams<{ serverId?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; + const sessionAgents = useSessionStore( + (state) => (serverId ? state.sessions[serverId]?.agents : undefined) + ); useEffect(() => { if (!serverId) { return; } - router.replace(buildHostAgentDraftRoute(serverId) as any); - }, [router, serverId]); + + const visibleAgents = sessionAgents + ? Array.from(sessionAgents.values()).filter( + (agent) => !agent.archivedAt && agent.labels.ui === "true" + ) + : []; + visibleAgents.sort( + (left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime() + ); + + const primaryAgent = visibleAgents[0]; + if (primaryAgent?.cwd?.trim()) { + router.replace( + buildHostWorkspaceAgentRoute( + serverId, + primaryAgent.cwd.trim(), + primaryAgent.id + ) as any + ); + return; + } + + router.replace(buildHostDraftRoute(serverId) as any); + }, [router, serverId, sessionAgents]); return null; } - diff --git a/packages/app/src/app/h/[serverId]/new.tsx b/packages/app/src/app/h/[serverId]/new.tsx new file mode 100644 index 000000000..76fe78ef2 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/new.tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from "expo-router"; +import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; + +export default function HostDraftRoute() { + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId : ""; + + return ; +} diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/agent/[agentId].tsx new file mode 100644 index 000000000..f401134fd --- /dev/null +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/agent/[agentId].tsx @@ -0,0 +1,20 @@ +import { useLocalSearchParams } from "expo-router"; +import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; + +export default function HostWorkspaceAgentRoute() { + const params = useLocalSearchParams<{ + serverId?: string; + workspaceId?: string; + agentId?: string; + }>(); + + const agentId = typeof params.agentId === "string" ? params.agentId : ""; + + return ( + + ); +} diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx new file mode 100644 index 000000000..fe1ac5ca8 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -0,0 +1,14 @@ +import { useLocalSearchParams } from "expo-router"; +import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; + +export default function HostWorkspaceIndexRoute() { + const params = useLocalSearchParams<{ serverId?: string; workspaceId?: string }>(); + + return ( + + ); +} diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/terminal/[terminalId].tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/terminal/[terminalId].tsx new file mode 100644 index 000000000..6ce85e057 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/terminal/[terminalId].tsx @@ -0,0 +1,21 @@ +import { useLocalSearchParams } from "expo-router"; +import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; + +export default function HostWorkspaceTerminalRoute() { + const params = useLocalSearchParams<{ + serverId?: string; + workspaceId?: string; + terminalId?: string; + }>(); + + const terminalId = + typeof params.terminalId === "string" ? params.terminalId : ""; + + return ( + + ); +} diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 92b6e0dbb..71049b6cc 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -5,7 +5,6 @@ import { useUnistyles } from "react-native-unistyles"; import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useFormPreferences } from "@/hooks/use-form-preferences"; -import { buildHostAgentDraftRoute } from "@/utils/host-routes"; export default function Index() { const router = useRouter(); @@ -45,7 +44,7 @@ export default function Index() { if (!targetServerId) { return; } - router.replace(buildHostAgentDraftRoute(targetServerId) as any); + router.replace(`/h/${encodeURIComponent(targetServerId)}` as any); }, [preferencesLoading, registryLoading, router, targetServerId]); if (registryLoading || preferencesLoading) { diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 611fac200..15b5d78a3 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -34,7 +34,6 @@ import { } from "@/utils/navigation-timing"; import { buildHostAgentDetailRoute, - parseHostAgentRouteFromPathname, } from "@/utils/host-routes"; interface AgentListProps { @@ -85,7 +84,7 @@ interface AgentListRowProps { agent: AggregatedAgent; selectedAgentId?: string; showCheckoutInfo: boolean; - onPress: (serverId: string, agentId: string) => void; + onPress: (agent: AggregatedAgent) => void; onLongPress: (agent: AggregatedAgent) => void; } @@ -117,7 +116,7 @@ function AgentListRow({ hovered && styles.agentItemHovered, pressed && styles.agentItemPressed, ]} - onPress={() => onPress(agent.serverId, agent.id)} + onPress={() => onPress(agent)} onLongPress={() => onLongPress(agent)} testID={`agent-row-${agent.serverId}-${agent.id}`} > @@ -169,11 +168,13 @@ export function AgentList({ const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); const handleAgentPress = useCallback( - (serverId: string, agentId: string) => { + (agent: AggregatedAgent) => { if (isActionSheetVisible) { return; } + const serverId = agent.serverId; + const agentId = agent.id; const navigationKey = buildAgentNavigationKey(serverId, agentId); startNavigationTiming(navigationKey, { from: "home", @@ -181,12 +182,12 @@ export function AgentList({ params: { serverId, agentId }, }); - const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); + const shouldReplace = pathname.startsWith("/h/"); const navigate = shouldReplace ? router.replace : router.push; onAgentSelect?.(); - navigate(buildHostAgentDetailRoute(serverId, agentId) as any); + navigate(buildHostAgentDetailRoute(serverId, agentId, agent.cwd) as any); }, [isActionSheetVisible, pathname, onAgentSelect] ); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 2e967f774..a14e571b4 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -173,8 +173,14 @@ export const AgentStreamView = forwardRef { - setExplorerTabForCheckout({ serverId, cwd, isGit, tab }); + setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab }); }, - [cwd, isGit, serverId, setExplorerTabForCheckout] + [isGit, serverId, setExplorerTabForCheckout, workspaceRoot] ); // Swipe gesture to close (swipe right on mobile) @@ -132,7 +136,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb Gesture.Pan() .withRef(closeGestureRef) .enabled(enableSidebarCloseGesture) - // Use manual activation so child views (e.g. WebView terminals) keep touch streams + // Use manual activation so child views keep touch streams // unless we detect an intentional right-swipe close. .manualActivation(true) .onTouchesDown((event) => { @@ -301,8 +305,8 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb onTabPress={handleTabPress} onClose={() => handleClose("header-close-button")} serverId={serverId} - agentId={agentId} - cwd={cwd} + workspaceId={workspaceId} + workspaceRoot={workspaceRoot} isGit={isGit} isMobile={isMobile} /> @@ -334,8 +338,8 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb onTabPress={handleTabPress} onClose={() => handleClose("desktop-close-button")} serverId={serverId} - agentId={agentId} - cwd={cwd} + workspaceId={workspaceId} + workspaceRoot={workspaceRoot} isGit={isGit} isMobile={false} /> @@ -348,8 +352,8 @@ interface SidebarContentProps { onTabPress: (tab: ExplorerTab) => void; onClose: () => void; serverId: string; - agentId: string; - cwd: string; + workspaceId?: string | null; + workspaceRoot: string; isGit: boolean; isMobile: boolean; } @@ -359,8 +363,8 @@ function SidebarContent({ onTabPress, onClose, serverId, - agentId, - cwd, + workspaceId, + workspaceRoot, isGit, isMobile, }: SidebarContentProps) { @@ -403,20 +407,6 @@ function SidebarContent({ Files - onTabPress("terminals")} - > - - Terminals - - {isMobile && ( @@ -430,13 +420,18 @@ function SidebarContent({ {/* Content based on active tab */} {resolvedTab === "changes" && ( - + )} {resolvedTab === "files" && ( - - )} - {resolvedTab === "terminals" && ( - + )} diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 7a707c049..f5d23f755 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -60,6 +60,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; +import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { usePanelStore, DEFAULT_EXPLORER_FILES_SPLIT_RATIO, @@ -81,7 +82,8 @@ const INDENT_PER_LEVEL = 12; interface FileExplorerPaneProps { serverId: string; - agentId: string; + workspaceId?: string | null; + workspaceRoot: string; } interface TreeRow { @@ -89,7 +91,11 @@ interface TreeRow { depth: number; } -export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { +export function FileExplorerPane({ + serverId, + workspaceId, + workspaceRoot, +}: FileExplorerPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -100,23 +106,40 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { () => daemons.find((daemon) => daemon.serverId === serverId), [daemons, serverId] ); - const agentExists = useSessionStore((state) => - agentId && state.sessions[serverId] - ? state.sessions[serverId]?.agents.has(agentId) - : false + const normalizedWorkspaceRoot = useMemo( + () => workspaceRoot.trim(), + [workspaceRoot] ); + const workspaceStateKey = useMemo( + () => + buildWorkspaceExplorerStateKey({ + workspaceId, + workspaceRoot: normalizedWorkspaceRoot, + }), + [normalizedWorkspaceRoot, workspaceId] + ); + const workspaceScopeId = useMemo( + () => workspaceId?.trim() || normalizedWorkspaceRoot, + [normalizedWorkspaceRoot, workspaceId] + ); + const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot); const explorerState = useSessionStore((state) => - agentId && state.sessions[serverId] - ? state.sessions[serverId]?.fileExplorer.get(agentId) + workspaceStateKey && state.sessions[serverId] + ? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey) : undefined ); const { + workspaceStateKey: actionsWorkspaceStateKey, requestDirectoryListing, requestFilePreview, requestFileDownloadToken, selectExplorerEntry, - } = useFileExplorerActions(serverId); + } = useFileExplorerActions({ + serverId, + workspaceId, + workspaceRoot: normalizedWorkspaceRoot, + }); const sortOption = usePanelStore((state) => state.explorerSortOption); const setSortOption = usePanelStore((state) => state.setExplorerSortOption); const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio); @@ -155,20 +178,29 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { const previewSnapPoints = useMemo(() => ["70%", "95%"], []); const hasInitializedRef = useRef(false); + useEffect(() => { - if (!agentId || !requestDirectoryListing) { + hasInitializedRef.current = false; + setExpandedPaths(new Set(["."])); + }, [actionsWorkspaceStateKey]); + + useEffect(() => { + if (!hasWorkspaceScope) { return; } if (hasInitializedRef.current) { return; } hasInitializedRef.current = true; - requestDirectoryListing(agentId, ".", { recordHistory: false, setCurrentPath: false }); - }, [agentId, requestDirectoryListing]); + void requestDirectoryListing(".", { + recordHistory: false, + setCurrentPath: false, + }); + }, [hasWorkspaceScope, requestDirectoryListing]); // Expand ancestor directories when a file is selected (e.g., from an inline path click) useEffect(() => { - if (!agentId || !selectedEntryPath || !requestDirectoryListing) { + if (!selectedEntryPath || !hasWorkspaceScope) { return; } const parentDir = getParentDirectory(selectedEntryPath); @@ -182,10 +214,13 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { ancestors.forEach((path) => { if (!directories.has(path)) { - requestDirectoryListing(agentId, path, { recordHistory: false, setCurrentPath: false }); + void requestDirectoryListing(path, { + recordHistory: false, + setCurrentPath: false, + }); } }); - }, [agentId, directories, requestDirectoryListing, selectedEntryPath]); + }, [directories, hasWorkspaceScope, requestDirectoryListing, selectedEntryPath]); // Open/close preview sheet based on selection useEffect(() => { @@ -200,15 +235,12 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { }, [isMobile, selectedEntryPath]); const handleClosePreview = useCallback(() => { - if (!agentId) { - return; - } - selectExplorerEntry(agentId, null); - }, [agentId, selectExplorerEntry]); + selectExplorerEntry(null); + }, [selectExplorerEntry]); const handleToggleDirectory = useCallback( (entry: ExplorerEntry) => { - if (!agentId || !requestDirectoryListing) { + if (!hasWorkspaceScope) { return; } @@ -225,21 +257,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { }); if (nextExpanded && !directories.has(entry.path)) { - requestDirectoryListing(agentId, entry.path, { recordHistory: false, setCurrentPath: false }); + void requestDirectoryListing(entry.path, { + recordHistory: false, + setCurrentPath: false, + }); } }, - [agentId, directories, expandedPaths, requestDirectoryListing] + [directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing] ); const handleOpenFile = useCallback( (entry: ExplorerEntry) => { - if (!agentId || !requestFilePreview) { + if (!hasWorkspaceScope) { return; } - selectExplorerEntry(agentId, entry.path); - requestFilePreview(agentId, entry.path); + selectExplorerEntry(entry.path); + void requestFilePreview(entry.path); }, - [agentId, requestFilePreview, selectExplorerEntry] + [hasWorkspaceScope, requestFilePreview, selectExplorerEntry] ); const handleEntryPress = useCallback( @@ -260,20 +295,26 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { const startDownload = useDownloadStore((state) => state.startDownload); const handleDownloadEntry = useCallback( (entry: ExplorerEntry) => { - if (!agentId || !requestFileDownloadToken || entry.kind !== "file") { + if (!workspaceScopeId || entry.kind !== "file") { return; } startDownload({ serverId, - agentId, + scopeId: workspaceScopeId, fileName: entry.name, path: entry.path, daemonProfile, - requestFileDownloadToken, + requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath), }); }, - [agentId, serverId, daemonProfile, requestFileDownloadToken, startDownload] + [ + daemonProfile, + requestFileDownloadToken, + serverId, + startDownload, + workspaceScopeId, + ] ); const handleSortCycle = useCallback(() => { @@ -283,9 +324,9 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { }, [sortOption, setSortOption]); const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({ - queryKey: ["fileExplorerRefresh", serverId, agentId], + queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey], queryFn: async () => { - if (!agentId) { + if (!hasWorkspaceScope) { return null; } @@ -296,12 +337,12 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { await Promise.all([ ...directoryPaths.map((path) => - requestDirectoryListing(agentId, path, { + requestDirectoryListing(path, { recordHistory: false, setCurrentPath: false, }) ), - ...(selectedEntryPath ? [requestFilePreview(agentId, selectedEntryPath)] : []), + ...(selectedEntryPath ? [requestFilePreview(selectedEntryPath)] : []), ]); return null; }, @@ -587,15 +628,15 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { ); const handleBackFromError = useCallback(() => { - if (!agentId || !requestDirectoryListing) { + if (!hasWorkspaceScope) { return; } - selectExplorerEntry(agentId, null); - requestDirectoryListing(agentId, errorRecoveryPath, { + selectExplorerEntry(null); + void requestDirectoryListing(errorRecoveryPath, { recordHistory: false, setCurrentPath: true, }); - }, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]); + }, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]); const handleTreeListScroll = useCallback( (event: NativeSyntheticEvent) => { @@ -615,10 +656,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { [showDesktopWebScrollbar, treeScrollbarMetrics] ); - if (!agentExists) { + if (!hasWorkspaceScope) { return ( - Agent not found + Workspace is unavailable ); } @@ -640,12 +681,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { { - if (agentId) { - requestDirectoryListing(agentId, ".", { - recordHistory: false, - setCurrentPath: false, - }); - } + void requestDirectoryListing(".", { + recordHistory: false, + setCurrentPath: false, + }); }} > Retry diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 49f2318f3..68db8c033 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -464,7 +464,7 @@ function DiffFileBody({ interface GitDiffPaneProps { serverId: string; - agentId: string; + workspaceId?: string | null; cwd: string; } @@ -472,7 +472,7 @@ type DiffFlatItem = | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } | { type: "body"; file: ParsedDiffFile; fileIndex: number }; -export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { +export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -750,7 +750,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { perfLog(DIFF_PANE_LOG_TAG, { event: "files_snapshot", serverId, - agentId, + workspaceId: workspaceId ?? cwd, fileCount: diffMetrics.fileCount, hunkCount: diffMetrics.hunkCount, lineCount: diffMetrics.lineCount, @@ -758,7 +758,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { isLoading: isDiffLoading, isFetching: isDiffFetching, }); - }, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]); + }, [cwd, diffMetrics, isDiffFetching, isDiffLoading, serverId, workspaceId]); const commitStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "commit" }) diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index ca7c988a5..3a2d7eec4 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,70 +1,62 @@ -import { - useCallback, - useMemo, - useState, - useEffect, - useRef, - useSyncExternalStore, -} from "react"; -import { View, Pressable, Text, Platform } from "react-native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react' +import { View, Pressable, Text, Platform } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' import Animated, { useAnimatedStyle, interpolate, Extrapolation, runOnJS, -} from "react-native-reanimated"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { Plus, ListFilter, Settings, Users } from "lucide-react-native"; -import { router, usePathname } from "expo-router"; -import { usePanelStore } from "@/stores/panel-store"; -import { SidebarAgentList } from "./sidebar-agent-list"; -import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; -import { useSidebarAgentsList } from "@/hooks/use-sidebar-agents-list"; -import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; -import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { Combobox } from "@/components/ui/combobox"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { formatConnectionStatus } from "@/utils/daemons"; -import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; +} from 'react-native-reanimated' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' +import { Plus, Settings, Users } from 'lucide-react-native' +import { router, usePathname } from 'expo-router' +import { usePanelStore } from '@/stores/panel-store' +import { SidebarAgentList } from './sidebar-agent-list' +import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton' +import { useSidebarAgentsList } from '@/hooks/use-sidebar-agents-list' +import { useSidebarAnimation } from '@/contexts/sidebar-animation-context' +import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window' +import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' +import { Combobox } from '@/components/ui/combobox' +import { useDaemonRegistry } from '@/contexts/daemon-registry-context' +import { getHostRuntimeStore } from '@/runtime/host-runtime' +import { useSessionStore } from '@/stores/session-store' +import { formatConnectionStatus } from '@/utils/daemons' +import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout' import { checkoutStatusQueryKey, type CheckoutStatusPayload, -} from "@/hooks/use-checkout-status-query"; -import { queryClient } from "@/query/query-client"; +} from '@/hooks/use-checkout-status-query' +import { queryClient } from '@/query/query-client' import { buildNewAgentRoute, resolveNewAgentWorkingDir, resolveSelectedAgentForNewAgent, -} from "@/utils/new-agent-routing"; +} from '@/utils/new-agent-routing' import { buildHostAgentsRoute, buildHostSettingsRoute, mapPathnameToServer, parseServerIdFromPathname, -} from "@/utils/host-routes"; +} from '@/utils/host-routes' -const DESKTOP_SIDEBAR_WIDTH = 320; +const DESKTOP_SIDEBAR_WIDTH = 320 interface LeftSidebarProps { - selectedAgentId?: string; + selectedAgentId?: string } export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const mobileView = usePanelStore((state) => state.mobileView); - const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); - const closeToAgent = usePanelStore((state) => state.closeToAgent); - const pathname = usePathname(); - const { daemons } = useDaemonRegistry(); - const runtime = getHostRuntimeStore(); + const { theme } = useUnistyles() + const insets = useSafeAreaInsets() + const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' + const mobileView = usePanelStore((state) => state.mobileView) + const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen) + const closeToAgent = usePanelStore((state) => state.closeToAgent) + const pathname = usePathname() + const { daemons } = useDaemonRegistry() + const runtime = getHostRuntimeStore() const runtimeConnectionStatusSignature = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => @@ -72,76 +64,58 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { .map( (daemon) => `${daemon.serverId}:${ - runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" + runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' }` ) - .join("|"), + .join('|'), () => daemons .map( (daemon) => `${daemon.serverId}:${ - runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" + runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' }` ) - .join("|") - ); - const activeServerIdFromPath = useMemo( - () => parseServerIdFromPathname(pathname), - [pathname] - ); - const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null; + .join('|') + ) + const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]) + const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null const activeHostLabel = useMemo(() => { - if (!activeServerId) return "No host"; - const daemon = daemons.find((entry) => entry.serverId === activeServerId); - const trimmed = daemon?.label?.trim(); - return trimmed && trimmed.length > 0 ? trimmed : activeServerId; - }, [activeServerId, daemons]); + if (!activeServerId) return 'No host' + const daemon = daemons.find((entry) => entry.serverId === activeServerId) + const trimmed = daemon?.label?.trim() + return trimmed && trimmed.length > 0 ? trimmed : activeServerId + }, [activeServerId, daemons]) const activeHostStatus = activeServerId - ? runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting" - : "idle"; + ? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting') + : 'idle' const activeHostStatusColor = - activeHostStatus === "online" + activeHostStatus === 'online' ? theme.colors.palette.green[400] - : activeHostStatus === "connecting" + : activeHostStatus === 'connecting' ? theme.colors.palette.amber[500] - : theme.colors.palette.red[500]; + : theme.colors.palette.red[500] const hostOptions = useMemo( () => daemons.map((daemon) => ({ id: daemon.serverId, label: daemon.label?.trim() || daemon.serverId, description: formatConnectionStatus( - runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" + runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' ), })), [daemons, runtime, runtimeConnectionStatusSignature] - ); - const hostTriggerRef = useRef(null); - const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); - const projectFilterAnchorRef = useRef(null); - const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false); + ) + const hostTriggerRef = useRef(null) + const [isHostPickerOpen, setIsHostPickerOpen] = useState(false) // Derive isOpen from the unified panel state - const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; - const [selectedProjectFilterKey, setSelectedProjectFilterKey] = useState( - null - ); + const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen - const { - entries, - projectFilterOptions, - hasMoreEntries, - isInitialLoad, - isRevalidating, - refreshAll, - } = useSidebarAgentsList({ + const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarAgentsList({ serverId: activeServerId, - selectedProjectFilterKey, - }); - useEffect(() => { - setSelectedProjectFilterKey(null); - }, [activeServerId]); + enabled: isOpen, + }) const { translateX, backdropOpacity, @@ -150,157 +124,116 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { animateToClose, isGesturing, closeGestureRef, - } = useSidebarAnimation(); - const dragHandlers = useTauriDragHandlers(); - const trafficLightPadding = useTrafficLightPadding(); + } = useSidebarAnimation() + const dragHandlers = useTauriDragHandlers() + const trafficLightPadding = useTrafficLightPadding() // Track user-initiated refresh to avoid showing spinner on background revalidation - const [isManualRefresh, setIsManualRefresh] = useState(false); + const [isManualRefresh, setIsManualRefresh] = useState(false) const handleRefresh = useCallback(() => { - setIsManualRefresh(true); - refreshAll(); - }, [refreshAll]); + setIsManualRefresh(true) + refreshAll() + }, [refreshAll]) // Reset manual refresh flag when revalidation completes useEffect(() => { if (!isRevalidating && isManualRefresh) { - setIsManualRefresh(false); + setIsManualRefresh(false) } - }, [isRevalidating, isManualRefresh]); + }, [isRevalidating, isManualRefresh]) const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore( (s) => s.setSidebarShortcutAgentKeys - ); - const sidebarShortcutAgentKeys = useMemo(() => { - return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`); - }, [entries]); - + ) useEffect(() => { - setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys); - }, [setSidebarShortcutAgentKeys, sidebarShortcutAgentKeys]); + setSidebarShortcutAgentKeys([]) + }, [setSidebarShortcutAgentKeys]) const handleClose = useCallback(() => { - closeToAgent(); - }, [closeToAgent]); + closeToAgent() + }, [closeToAgent]) const handleCreateAgentClean = useCallback(() => { - let targetServerId = activeServerId; - let targetWorkingDir: string | null = null; + let targetServerId = activeServerId + let targetWorkingDir: string | null = null const selectedAgent = resolveSelectedAgentForNewAgent({ pathname, selectedAgentId, - }); + }) if (selectedAgent) { - targetServerId = selectedAgent.serverId; + targetServerId = selectedAgent.serverId const agent = useSessionStore .getState() - .sessions[selectedAgent.serverId] - ?.agents?.get(selectedAgent.agentId); - const cwd = agent?.cwd?.trim(); + .sessions[selectedAgent.serverId]?.agents?.get(selectedAgent.agentId) + const cwd = agent?.cwd?.trim() if (cwd) { const checkout = queryClient.getQueryData( checkoutStatusQueryKey(selectedAgent.serverId, cwd) - ) ?? null; - targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout); + ) ?? null + targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout) } } if (!targetServerId) { - return; + return } - router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any); - }, [activeServerId, pathname, selectedAgentId]); + router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any) + }, [activeServerId, pathname, selectedAgentId]) // Mobile: close sidebar and navigate const handleCreateAgentCleanMobile = useCallback(() => { - closeToAgent(); - handleCreateAgentClean(); - }, [closeToAgent, handleCreateAgentClean]); + closeToAgent() + handleCreateAgentClean() + }, [closeToAgent, handleCreateAgentClean]) // Desktop: just navigate, don't close const handleCreateAgentCleanDesktop = useCallback(() => { - handleCreateAgentClean(); - }, [handleCreateAgentClean]); + handleCreateAgentClean() + }, [handleCreateAgentClean]) // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { if (!activeServerId) { - return; + return } - closeToAgent(); - router.push(buildHostSettingsRoute(activeServerId) as any); - }, [activeServerId, closeToAgent]); + closeToAgent() + router.push(buildHostSettingsRoute(activeServerId) as any) + }, [activeServerId, closeToAgent]) // Desktop: just navigate, don't close const handleSettingsDesktop = useCallback(() => { if (!activeServerId) { - return; + return } - router.push(buildHostSettingsRoute(activeServerId) as any); - }, [activeServerId]); - - // Mobile: close sidebar when agent is selected - // Snap immediately since navigation interrupts animations - const handleAgentSelectMobile = useCallback(() => { - translateX.value = -windowWidth; - backdropOpacity.value = 0; - closeToAgent(); - }, [closeToAgent, translateX, backdropOpacity, windowWidth]); + router.push(buildHostSettingsRoute(activeServerId) as any) + }, [activeServerId]) const handleViewMore = useCallback(() => { if (!activeServerId) { - return; + return } if (isMobile) { - translateX.value = -windowWidth; - backdropOpacity.value = 0; - closeToAgent(); + translateX.value = -windowWidth + backdropOpacity.value = 0 + closeToAgent() } - router.push(buildHostAgentsRoute(activeServerId) as any); - }, [ - activeServerId, - backdropOpacity, - closeToAgent, - isMobile, - translateX, - windowWidth, - ]); - - const listFooterComponent = useMemo(() => { - if (!hasMoreEntries) { - return null; - } - - return ( - - {({ hovered }) => ( - - View more - - )} - - ); - }, [handleViewMore, hasMoreEntries]); + router.push(buildHostAgentsRoute(activeServerId) as any) + }, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]) const handleHostSelect = useCallback( (nextServerId: string) => { if (!nextServerId) { - return; + return } - const nextPath = mapPathnameToServer(pathname, nextServerId); - setIsHostPickerOpen(false); - router.push(nextPath as any); + const nextPath = mapPathnameToServer(pathname, nextServerId) + setIsHostPickerOpen(false) + router.push(nextPath as any) }, [pathname] - ); + ) // Close gesture (swipe left to close when sidebar is open) // Only activates on leftward swipe, fails on rightward or vertical movement @@ -315,50 +248,48 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { // Fail if vertical movement happens first (allow vertical scroll) .failOffsetY([-10, 10]) .onStart(() => { - isGesturing.value = true; + isGesturing.value = true }) .onUpdate((event) => { - if (!isMobile) return; + if (!isMobile) return // Only allow swiping left (closing) - const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)); - translateX.value = newTranslateX; + const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)) + translateX.value = newTranslateX backdropOpacity.value = interpolate( newTranslateX, [-windowWidth, 0], [0, 1], Extrapolation.CLAMP - ); + ) }) .onEnd((event) => { - isGesturing.value = false; - if (!isMobile) return; - const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; + isGesturing.value = false + if (!isMobile) return + const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 if (shouldClose) { - animateToClose(); - runOnJS(handleClose)(); + animateToClose() + runOnJS(handleClose)() } else { - animateToOpen(); + animateToOpen() } }) .onFinalize(() => { - isGesturing.value = false; - }); - + isGesturing.value = false + }) const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], - })); + })) const backdropAnimatedStyle = useAnimatedStyle(() => ({ opacity: backdropOpacity.value, - pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", - })); - + pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none', + })) // Render mobile sidebar // On web, keep the overlay interactive only while the sidebar is open. // This preserves swipe/scroll behavior without blocking taps when closed. - const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; + const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none' if (isMobile) { return ( @@ -391,55 +322,30 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { size={theme.iconSize.md} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} /> - New agent + + New agent + )} - {projectFilterOptions.length > 0 ? ( - setIsProjectFilterOpen(true)} - > - {({ hovered }) => ( - - )} - - ) : null} - {/* Middle: scrollable agent list */} + {/* Middle: scrollable project/workspace tree */} {isInitialLoad ? ( ) : ( )} @@ -457,10 +363,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { disabled={hostOptions.length === 0} > {activeHostLabel} @@ -505,7 +408,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { - ); + ) } // Desktop: no edge swipe, just show/hide based on isOpen if (!isOpen) { - return null; + return null } return ( @@ -532,10 +435,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { {trafficLightPadding.top > 0 ? ( ) : null} - + - New agent + + New agent + )} - {projectFilterOptions.length > 0 ? ( - setIsProjectFilterOpen(true)} - > - {({ hovered }) => ( - - )} - - ) : null} - {/* Middle: scrollable agent list */} + {/* Middle: scrollable project/workspace tree */} {isInitialLoad ? ( ) : ( )} @@ -611,12 +484,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { onPress={() => setIsHostPickerOpen(true)} disabled={hostOptions.length === 0} > - + {activeHostLabel} @@ -660,7 +528,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { - ); + ) } const styles = StyleSheet.create((theme) => ({ backdrop: { ...StyleSheet.absoluteFillObject, - backgroundColor: "rgba(0, 0, 0, 0.5)", + backgroundColor: 'rgba(0, 0, 0, 0.5)', }, backdropPressable: { flex: 1, }, mobileSidebar: { - position: "absolute", + position: 'absolute', top: 0, left: 0, bottom: 0, backgroundColor: theme.colors.surface0, - overflow: "hidden", + overflow: 'hidden', }, sidebarContent: { flex: 1, minHeight: 0, - overflow: "hidden", + overflow: 'hidden', }, desktopSidebar: { borderRightWidth: 1, @@ -706,36 +574,25 @@ const styles = StyleSheet.create((theme) => ({ md: HEADER_INNER_HEIGHT, }, paddingHorizontal: theme.spacing[2], - justifyContent: "center", + justifyContent: 'center', borderBottomWidth: 1, borderBottomColor: theme.colors.border, - userSelect: "none", + userSelect: 'none', }, sidebarHeaderRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', gap: theme.spacing[2], }, newAgentButton: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], flexShrink: 0, }, - filterButton: { - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1], - borderRadius: theme.borderRadius.md, - }, - filterButtonActive: { - backgroundColor: theme.colors.accent, - }, newAgentButtonHovered: {}, newAgentButtonText: { fontSize: theme.fontSize.base, @@ -746,9 +603,9 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, hostTrigger: { - flexDirection: "row", - alignItems: "center", - justifyContent: "flex-start", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', gap: theme.spacing[2], minWidth: 0, paddingVertical: theme.spacing[1], @@ -773,9 +630,9 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, }, sidebarFooter: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], borderTopWidth: 1, @@ -788,38 +645,19 @@ const styles = StyleSheet.create((theme) => ({ marginRight: theme.spacing[2], }, footerIconRow: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], flexShrink: 0, }, footerIconButton: { width: 28, height: 28, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], }, - listViewMoreButton: { - marginTop: theme.spacing[2], - marginHorizontal: theme.spacing[2], - marginBottom: theme.spacing[1], - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.surface1, - alignItems: "center", - justifyContent: "center", - paddingVertical: theme.spacing[2], - }, - listViewMoreButtonText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - listViewMoreButtonTextHovered: { - color: theme.colors.foreground, - }, hostPickerList: { gap: theme.spacing[2], }, @@ -842,10 +680,10 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface0, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, - alignItems: "center", + alignItems: 'center', }, hostPickerCancelText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, -})); +})) diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index e8ee37c46..9da081053 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -1,12 +1,5 @@ -import { - View, - Text, - Pressable, - Image, - Platform, - ActivityIndicator, -} from "react-native"; -import { useQueries } from "@tanstack/react-query"; +import { View, Text, Pressable, Image, Platform } from 'react-native' +import { useQueries } from '@tanstack/react-query' import { useCallback, useMemo, @@ -15,107 +8,173 @@ import { useRef, type ReactElement, type MutableRefObject, -} from "react"; -import { router, usePathname } from "expo-router"; +} from 'react' +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 { DraggableList, type DraggableRenderItemInfo } from './draggable-list' +import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' +import { projectIconQueryKey } from '@/hooks/use-project-icon-query' +import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from '@/utils/host-routes' import { - StyleSheet, - UnistylesRuntime, - useUnistyles, -} from "react-native-unistyles"; -import { type GestureType } from "react-native-gesture-handler"; -import { Archive, Check, X } from "lucide-react-native"; -import { - DraggableList, - type DraggableRenderItemInfo, -} from "./draggable-list"; -import { - getHostRuntimeStore, - isHostRuntimeConnected, -} from "@/runtime/host-runtime"; -import { - buildAgentNavigationKey, - startNavigationTiming, -} from "@/utils/navigation-timing"; -import { - buildHostAgentDetailRoute, - parseHostAgentRouteFromPathname, -} from "@/utils/host-routes"; -import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; -import { - type SidebarAgentListEntry, - type SidebarProjectFilterOption, -} from "@/hooks/use-sidebar-agents-list"; -import type { ProjectIcon } from "@server/shared/messages"; -import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { getIsTauri } from "@/constants/layout"; -import { AgentStatusDot } from "@/components/agent-status-dot"; -import { Combobox } from "@/components/ui/combobox"; -import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; -import { parseRepoNameFromRemoteUrl } from "@/utils/agent-grouping"; -import { formatTimeAgo } from "@/utils/time"; -import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state"; -import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; + type SidebarProjectEntry, + type SidebarWorkspaceEntry, +} from '@/hooks/use-sidebar-agents-list' +import { useSidebarOrderStore } from '@/stores/sidebar-order-store' +import { formatTimeAgo } from '@/utils/time' -type EntryData = SidebarAgentListEntry; +type SidebarTreeRow = + | { + kind: 'project' + rowKey: string + project: SidebarProjectEntry + displayName: string + } + | { + kind: 'workspace' + rowKey: string + projectKey: string + workspace: SidebarWorkspaceEntry + } -function toProjectIconDataUri(icon: ProjectIcon | null): string | null { +function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { - return null; + return null } - return `data:${icon.mimeType};base64,${icon.data}`; + return `data:${icon.mimeType};base64,${icon.data}` } interface SidebarAgentListProps { - isOpen?: boolean; - entries: SidebarAgentListEntry[]; - projectFilterOptions: SidebarProjectFilterOption[]; - selectedProjectFilterKey: string | null; - onSelectedProjectFilterKeyChange: (key: string | null) => void; - isProjectFilterOpen: boolean; - onProjectFilterOpenChange: (open: boolean) => void; - projectFilterAnchorRef: MutableRefObject; - serverId: string | null; - isRefreshing?: boolean; - onRefresh?: () => void; - selectedAgentId?: string; - onAgentSelect?: () => void; - listFooterComponent?: ReactElement | null; + isOpen?: boolean + projects: SidebarProjectEntry[] + serverId: string | null + isRefreshing?: boolean + onRefresh?: () => void + listFooterComponent?: ReactElement | null /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ - parentGestureRef?: MutableRefObject; + parentGestureRef?: MutableRefObject } -interface ProjectFilterOptionRowProps { - option: SidebarProjectFilterOption; - selected: boolean; - iconDataUri: string | null; - displayName: string; - onSelect: (projectKey: string) => void; +interface ProjectRowProps { + project: SidebarProjectEntry + displayName: string + iconDataUri: string | null + collapsed: boolean + onToggle: () => void + onLongPress: () => void } -function ProjectFilterOptionRow({ - option, - selected, - iconDataUri, +interface WorkspaceRowProps { + workspace: SidebarWorkspaceEntry + onPress: () => void + onLongPress: () => void +} + +function deriveProjectDisplayName(input: { projectKey: string; projectName: string }): string { + const githubPrefix = 'remote:github.com/' + if (input.projectKey.startsWith(githubPrefix)) { + return input.projectKey.slice(githubPrefix.length) + } + + if (input.projectKey.startsWith('remote:')) { + const withoutPrefix = input.projectKey.slice('remote:'.length) + const slashIdx = withoutPrefix.indexOf('/') + if (slashIdx >= 0) { + const remotePath = withoutPrefix.slice(slashIdx + 1).trim() + if (remotePath.length > 0) { + return remotePath + } + } + return withoutPrefix + } + + const trimmedProjectName = input.projectName.trim() + if (trimmedProjectName.length > 0) { + return trimmedProjectName + } + + const normalized = input.projectKey.replace(/\\/g, '/').replace(/\/+$/, '') + const segments = normalized.split('/').filter(Boolean) + return segments[segments.length - 1] ?? input.projectKey +} + +function resolveWorkspaceBranchLabel(workspace: SidebarWorkspaceEntry): string { + const branch = workspace.branchName?.trim() + if (branch && branch.length > 0) { + return branch + } + return 'Unknown branch' +} + +function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string { + if (!workspace.createdAt) { + return 'No agents yet' + } + return formatTimeAgo(workspace.createdAt) +} + +function ProjectStatusDot({ bucket }: { bucket: SidebarProjectEntry['statusBucket'] }) { + const { theme } = useUnistyles() + + const color = + bucket === 'needs_input' + ? theme.colors.palette.amber[500] + : bucket === 'failed' + ? theme.colors.palette.red[500] + : bucket === 'running' + ? theme.colors.palette.blue[500] + : bucket === 'attention' + ? theme.colors.palette.green[500] + : theme.colors.border + + return +} + +function ProjectRow({ + project, displayName, - onSelect, -}: ProjectFilterOptionRowProps) { + iconDataUri, + collapsed, + onToggle, + onLongPress, +}: ProjectRowProps) { + const didLongPressRef = useRef(false) + + const handlePress = useCallback(() => { + if (didLongPressRef.current) { + didLongPressRef.current = false + return + } + onToggle() + }, [onToggle]) + + const handleLongPress = useCallback(() => { + didLongPressRef.current = true + onLongPress() + }, [onLongPress]) + return ( [ - styles.filterOption, - selected && styles.filterOptionSelected, - hovered && styles.filterOptionHovered, - pressed && styles.filterOptionPressed, + styles.projectRow, + hovered && styles.projectRowHovered, + pressed && styles.projectRowPressed, ]} - onPress={() => onSelect(option.projectKey)} + onPress={handlePress} + onLongPress={handleLongPress} + delayLongPress={200} + testID={`sidebar-project-row-${project.projectKey}`} > - + + {collapsed ? ( + + ) : ( + + )} + {iconDataUri ? ( - + ) : ( @@ -123,391 +182,143 @@ function ProjectFilterOptionRow({ )} - + + + + {displayName} - - {option.activeCount > 0 ? ( - {option.activeCount} - ) : null} - + {project.workspaces.length} - ); + ) } -interface SidebarAgentRowProps { - entry: SidebarAgentListEntry; - projectDisplayName: string; - projectIconDataUri: string | null; - isSelected: boolean; - isInSelectionMode: boolean; - isBatchSelected: boolean; - isArchiving: boolean; - shortcutNumber: number | null; - onPress: () => void; - onLongPress: () => void; - onArchive: () => Promise; -} +function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) { + const didLongPressRef = useRef(false) -function resolveBranchLabel(entry: SidebarAgentListEntry): string | null { - const checkout = entry.project.checkout; - if (!checkout.isGit) { - return null; - } - const branch = checkout.currentBranch; - if (!branch || branch === "HEAD" || branch === "main") { - return null; - } - return branch; -} - -function SidebarAgentRow({ - entry, - projectDisplayName, - projectIconDataUri, - isSelected, - isInSelectionMode, - isBatchSelected, - isArchiving, - shortcutNumber, - onPress, - onLongPress, - onArchive, -}: SidebarAgentRowProps) { - const { theme } = useUnistyles(); - const [isHovered, setIsHovered] = useState(false); - const [isArchiveHovered, setIsArchiveHovered] = useState(false); - const [isArchiveConfirmVisible, setIsArchiveConfirmVisible] = useState(false); - const hoverOutTimeoutRef = useRef | null>(null); - - const branchLabel = resolveBranchLabel(entry); - const relativeCreatedAt = formatTimeAgo(entry.agent.createdAt); - const isActive = isSidebarActiveAgent({ - status: entry.agent.status, - pendingPermissionCount: entry.agent.pendingPermissionCount, - requiresAttention: entry.agent.requiresAttention, - attentionReason: entry.agent.attentionReason, - }); - const shouldApplyInactiveStyles = !isActive && !isSelected; - const showArchive = - !isInSelectionMode && - shortcutNumber === null && - (isHovered || isArchiveHovered || isArchiveConfirmVisible || isArchiving); - - const clearHoverOutTimeout = useCallback(() => { - if (!hoverOutTimeoutRef.current) { - return; + const handlePress = useCallback(() => { + if (didLongPressRef.current) { + didLongPressRef.current = false + return } - clearTimeout(hoverOutTimeoutRef.current); - hoverOutTimeoutRef.current = null; - }, []); + onPress() + }, [onPress]) - useEffect(() => { - return () => clearHoverOutTimeout(); - }, [clearHoverOutTimeout]); - - useEffect(() => { - if (!isArchiving) { - setIsArchiveConfirmVisible(false); - } - }, [isArchiving]); - - const handleHoverIn = useCallback(() => { - clearHoverOutTimeout(); - setIsHovered(true); - }, [clearHoverOutTimeout]); - - const handleHoverOut = useCallback(() => { - clearHoverOutTimeout(); - hoverOutTimeoutRef.current = setTimeout(() => { - setIsHovered(false); - setIsArchiveHovered(false); - setIsArchiveConfirmVisible(false); - }, 50); - }, [clearHoverOutTimeout]); + const handleLongPress = useCallback(() => { + didLongPressRef.current = true + onLongPress() + }, [onLongPress]) return ( [ - styles.agentItem, - isSelected && styles.agentItemSelected, - isHovered && styles.agentItemHovered, - pressed && styles.agentItemPressed, + style={({ pressed, hovered = false }) => [ + styles.workspaceRow, + hovered && styles.workspaceRowHovered, + pressed && styles.workspaceRowPressed, ]} - onPress={onPress} - onLongPress={onLongPress} + onPress={handlePress} + onLongPress={handleLongPress} delayLongPress={200} - onPressIn={() => { - if (Platform.OS !== "web") { - return; - } - handleHoverIn(); - }} - onHoverIn={handleHoverIn} - onHoverOut={handleHoverOut} - testID={`agent-row-${entry.agent.serverId}-${entry.agent.id}`} + testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > - - - - - {entry.agent.title || "New agent"} - - - {showArchive ? ( - { - clearHoverOutTimeout(); - setIsHovered(true); - setIsArchiveHovered(true); - }} - onHoverOut={() => { - setIsArchiveHovered(false); - }} - onPress={(event) => { - event.stopPropagation(); - if (isArchiving) { - return; - } - if (!isArchiveConfirmVisible) { - setIsArchiveConfirmVisible(true); - return; - } - void onArchive(); - }} - style={styles.archiveButton} - disabled={isArchiving} - testID={ - isArchiveConfirmVisible || isArchiving - ? `agent-archive-confirm-${entry.agent.serverId}-${entry.agent.id}` - : `agent-archive-${entry.agent.serverId}-${entry.agent.id}` - } - > - {({ hovered: archiveHovered }) => - isArchiving ? ( - - ) : isArchiveConfirmVisible ? ( - - ) : ( - - ) - } - - ) : ( - {relativeCreatedAt} - )} - - {shortcutNumber !== null && !isInSelectionMode ? ( - - {shortcutNumber} - - ) : null} - - - - - {projectIconDataUri ? ( - - ) : ( - - - {projectDisplayName.charAt(0).toUpperCase()} - - - )} - - {projectDisplayName} - - {branchLabel ? ( - - - {branchLabel} - - - ) : null} - - - {isBatchSelected ? ( - - - - ) : null} + + {resolveWorkspaceBranchLabel(workspace)} + + + {resolveWorkspaceCreatedAtLabel(workspace)} + - ); + ) } -function deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys: string[]) { - const map = new Map(); - for (let i = 0; i < sidebarShortcutAgentKeys.length; i += 1) { - const key = sidebarShortcutAgentKeys[i]; - if (!key) continue; - map.set(key, i + 1); - } - return map; +function mergeWithRemainder(input: { + currentOrder: string[] + reorderedVisibleKeys: string[] +}): string[] { + const reorderedSet = new Set(input.reorderedVisibleKeys) + const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key)) + return [...input.reorderedVisibleKeys, ...remainder] } -function deriveProjectDisplayName(input: { - projectKey: string; - projectName: string; - remoteUrl: string | null; -}): string { - const remoteRepoName = parseRepoNameFromRemoteUrl(input.remoteUrl); - if (remoteRepoName) { - return remoteRepoName; +function hasVisibleOrderChanged(input: { + currentOrder: string[] + reorderedVisibleKeys: string[] +}): boolean { + const currentVisible = input.currentOrder.filter((key) => + input.reorderedVisibleKeys.includes(key) + ) + if (currentVisible.length !== input.reorderedVisibleKeys.length) { + return true } - - const githubPrefix = "remote:github.com/"; - if (input.projectKey.startsWith(githubPrefix)) { - return input.projectKey.slice(githubPrefix.length); - } - - if (input.projectKey.startsWith("remote:")) { - const withoutPrefix = input.projectKey.slice("remote:".length); - const slashIdx = withoutPrefix.indexOf("/"); - if (slashIdx >= 0) { - const remotePath = withoutPrefix.slice(slashIdx + 1).trim(); - if (remotePath.length > 0) { - return remotePath; - } - } - return withoutPrefix; - } - - const trimmedProjectName = input.projectName.trim(); - if (trimmedProjectName.length > 0) { - return trimmedProjectName; - } - - return input.projectKey; + return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key) } export function SidebarAgentList({ isOpen = true, - entries, - projectFilterOptions, - selectedProjectFilterKey, - onSelectedProjectFilterKeyChange, - isProjectFilterOpen, - onProjectFilterOpenChange, - projectFilterAnchorRef, + projects, serverId, isRefreshing = false, onRefresh, - selectedAgentId, - onAgentSelect, listFooterComponent, parentGestureRef, }: SidebarAgentListProps) { - const { theme } = useUnistyles(); - const pathname = usePathname(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; + const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' + const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile + const pathname = usePathname() + const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) - const [isSelectionMode, setIsSelectionMode] = useState(false); - const [selectedBatchKeys, setSelectedBatchKeys] = useState>(new Set()); - const { archiveAgent, isArchivingAgent } = useArchiveAgent(); - const getSidebarOrder = useSidebarOrderStore((state) => state.getOrder); - const setSidebarOrder = useSidebarOrderStore((state) => state.setOrder); - const removeSidebarOrderEntry = useSidebarOrderStore((state) => state.remove); + const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder) + const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder) + const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder) + const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder) - const altDown = useKeyboardShortcutsStore((s) => s.altDown); - const cmdOrCtrlDown = useKeyboardShortcutsStore((s) => s.cmdOrCtrlDown); - const sidebarShortcutAgentKeys = useKeyboardShortcutsStore( - (s) => s.sidebarShortcutAgentKeys - ); - const isTauri = getIsTauri(); - const showShortcutBadges = !isSelectionMode && (altDown || (isTauri && cmdOrCtrlDown)); - const shortcutIndexByAgentKey = useMemo( - () => deriveShortcutIndexByAgentKey(sidebarShortcutAgentKeys), - [sidebarShortcutAgentKeys] - ); - - const showProjectFilters = projectFilterOptions.length > 0; + useEffect(() => { + setCollapsedProjectKeys((prev) => { + const validProjectKeys = new Set(projects.map((project) => project.projectKey)) + const next = new Set() + for (const key of prev) { + if (validProjectKeys.has(key)) { + next.add(key) + } + } + return next + }) + }, [projects]) const projectIconRequests = useMemo(() => { - if (!isOpen) { - return []; + if (!isOpen || !serverId) { + return [] } - const unique = new Map(); - for (const option of projectFilterOptions) { - if (!option.serverId || !option.workingDir) { - continue; + const unique = new Map() + for (const project of projects) { + const cwd = project.iconWorkingDir.trim() + if (!cwd) { + continue } - unique.set(`${option.serverId}:${option.workingDir}`, { - serverId: option.serverId, - cwd: option.workingDir, - }); + unique.set(`${serverId}:${cwd}`, { serverId, cwd }) } - for (const entry of entries) { - const serverId = entry.agent.serverId; - const cwd = entry.project.checkout.cwd; - if (!serverId || !cwd) { - continue; - } - unique.set(`${serverId}:${cwd}`, { serverId, cwd }); - } - return Array.from(unique.values()); - }, [entries, isOpen, projectFilterOptions]); + return Array.from(unique.values()) + }, [isOpen, projects, serverId]) const projectIconQueries = useQueries({ queries: projectIconRequests.map((request) => ({ queryKey: projectIconQueryKey(request.serverId, request.cwd), queryFn: async () => { - const client = getHostRuntimeStore().getClient(request.serverId); + const client = getHostRuntimeStore().getClient(request.serverId) if (!client) { - return null; + return null } - const result = await client.requestProjectIcon(request.cwd); - return result.icon; + const result = await client.requestProjectIcon(request.cwd) + return result.icon }, select: toProjectIconDataUri, enabled: Boolean( isOpen && - getHostRuntimeStore().getClient(request.serverId) && - isHostRuntimeConnected( - getHostRuntimeStore().getSnapshot(request.serverId) - ) && - request.cwd + getHostRuntimeStore().getClient(request.serverId) && + isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) && + request.cwd ), staleTime: Infinity, gcTime: 1000 * 60 * 60, @@ -515,402 +326,244 @@ export function SidebarAgentList({ refetchOnWindowFocus: false, refetchOnReconnect: false, })), - }); - - const projectIconByQueryKey = useMemo(() => { - const map = new Map(); - for (let i = 0; i < projectIconRequests.length; i += 1) { - const request = projectIconRequests[i]; - if (!request) { - continue; - } - const dataUri = projectIconQueries[i]?.data ?? null; - map.set(`${request.serverId}:${request.cwd}`, dataUri); - } - return map; - }, [projectIconQueries, projectIconRequests]); + }) const projectIconByProjectKey = useMemo(() => { - const map = new Map(); - for (const option of projectFilterOptions) { - map.set( - option.projectKey, - projectIconByQueryKey.get(`${option.serverId}:${option.workingDir}`) ?? null - ); - } - for (const entry of entries) { - if (map.has(entry.project.projectKey)) { - continue; + const iconByServerAndCwd = new Map() + for (let index = 0; index < projectIconRequests.length; index += 1) { + const request = projectIconRequests[index] + if (!request) { + continue } - map.set( - entry.project.projectKey, - projectIconByQueryKey.get( - `${entry.agent.serverId}:${entry.project.checkout.cwd}` - ) ?? null - ); + iconByServerAndCwd.set( + `${request.serverId}:${request.cwd}`, + projectIconQueries[index]?.data ?? null + ) } - return map; - }, [entries, projectIconByQueryKey, projectFilterOptions]); - const handleSelectProject = useCallback( - (projectKey: string) => { - onSelectedProjectFilterKeyChange( - selectedProjectFilterKey === projectKey ? null : projectKey - ); - }, - [onSelectedProjectFilterKeyChange, selectedProjectFilterKey] - ); - - const handleClearProjectFilter = useCallback(() => { - onSelectedProjectFilterKeyChange(null); - }, [onSelectedProjectFilterKeyChange]); - - const handleAgentPress = useCallback( - (entry: SidebarAgentListEntry) => { - const key = `${entry.agent.serverId}:${entry.agent.id}`; - if (isSelectionMode) { - setSelectedBatchKeys((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - return; + const byProject = new Map() + for (const project of projects) { + const cwd = project.iconWorkingDir.trim() + if (!cwd || !serverId) { + byProject.set(project.projectKey, null) + continue } - - const navigationKey = buildAgentNavigationKey(entry.agent.serverId, entry.agent.id); - startNavigationTiming(navigationKey, { - from: "home", - to: "agent", - params: { serverId: entry.agent.serverId, agentId: entry.agent.id }, - }); - - const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); - const navigate = shouldReplace ? router.replace : router.push; - - onAgentSelect?.(); - navigate(buildHostAgentDetailRoute(entry.agent.serverId, entry.agent.id) as any); - }, - [isSelectionMode, onAgentSelect, pathname] - ); - - const handleAgentLongPress = useCallback((entry: SidebarAgentListEntry) => { - const key = `${entry.agent.serverId}:${entry.agent.id}`; - if (isSelectionMode) { - setSelectedBatchKeys((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - return; + byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null) } - setIsSelectionMode(true); - setSelectedBatchKeys(new Set([key])); - }, [isSelectionMode]); - const handleArchiveSingle = useCallback( - async (entry: SidebarAgentListEntry): Promise => { - await archiveAgent({ - serverId: entry.agent.serverId, - agentId: entry.agent.id, + return byProject + }, [projectIconQueries, projectIconRequests, projects, serverId]) + + const rows = useMemo(() => { + const next: SidebarTreeRow[] = [] + for (const project of projects) { + next.push({ + kind: 'project', + rowKey: `project:${project.projectKey}`, + project, + displayName: deriveProjectDisplayName({ + projectKey: project.projectKey, + projectName: project.projectName, + }), }) - .then(() => { - removeSidebarOrderEntry( - entry.agent.serverId, - `${entry.agent.serverId}:${entry.agent.id}` - ); - }) - .catch((error) => { - console.warn("[archive_agent] failed", error); - }); - }, - [archiveAgent, removeSidebarOrderEntry] - ); - const handleArchiveBatch = useCallback(() => { - if (selectedBatchKeys.size === 0) { - setIsSelectionMode(false); - return; - } - - const requests: Promise[] = []; - for (const key of selectedBatchKeys) { - const parsed = parseSidebarAgentKey(key); - if (!parsed) { - continue; + if (collapsedProjectKeys.has(project.projectKey)) { + continue } - requests.push( - archiveAgent({ - serverId: parsed.serverId, - agentId: parsed.agentId, + + for (const workspace of project.workspaces) { + next.push({ + kind: 'workspace', + rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`, + projectKey: project.projectKey, + workspace, }) - .then(() => { - removeSidebarOrderEntry(parsed.serverId, key); - }) - .catch((error) => { - console.warn("[archive_agent_batch] failed", { key, error }); - }) - ); + } } + return next + }, [collapsedProjectKeys, projects]) - void Promise.all(requests).finally(() => { - setSelectedBatchKeys(new Set()); - setIsSelectionMode(false); - }); - }, [archiveAgent, removeSidebarOrderEntry, selectedBatchKeys]); - - const handleSelectionBack = useCallback(() => { - setSelectedBatchKeys(new Set()); - setIsSelectionMode(false); - }, []); - - const handleListDragBegin = useCallback(() => { - // No-op: selection mode is resolved in handleDragEnd based on whether - // the item actually moved. This fires synchronously when drag() is called - // (i.e., on long press), before we know if the user will drag or release. - }, []); + const toggleProjectCollapsed = useCallback((projectKey: string) => { + setCollapsedProjectKeys((prev) => { + const next = new Set(prev) + if (next.has(projectKey)) { + next.delete(projectKey) + } else { + next.add(projectKey) + } + return next + }) + }, []) const renderRow = useCallback( - ({ item, drag }: DraggableRenderItemInfo) => { - const key = `${item.agent.serverId}:${item.agent.id}`; - const projectDisplayName = deriveProjectDisplayName({ - projectKey: item.project.projectKey, - projectName: item.project.projectName, - remoteUrl: item.project.checkout.remoteUrl, - }); + ({ item, drag }: DraggableRenderItemInfo) => { + if (item.kind === 'project') { + return ( + toggleProjectCollapsed(item.project.projectKey)} + onLongPress={drag} + /> + ) + } + + const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd) + const shouldReplace = Boolean(parseHostWorkspaceRouteFromPathname(pathname)) + const navigate = shouldReplace ? router.replace : router.push + return ( - handleAgentPress(item)} - onLongPress={() => { - drag(); - handleAgentLongPress(item); + { + if (!serverId) { + return + } + navigate(workspaceRoute as any) }} - onArchive={() => handleArchiveSingle(item)} + onLongPress={drag} /> - ); + ) }, [ - handleAgentLongPress, - handleAgentPress, - handleArchiveSingle, - isArchivingAgent, - isSelectionMode, - selectedAgentId, - selectedBatchKeys, - shortcutIndexByAgentKey, - showShortcutBadges, + collapsedProjectKeys, + pathname, projectIconByProjectKey, + serverId, + toggleProjectCollapsed, ] - ); + ) + + const keyExtractor = useCallback((entry: SidebarTreeRow) => entry.rowKey, []) - const keyExtractor = useCallback( - (entry: EntryData) => `${entry.agent.serverId}:${entry.agent.id}`, - [] - ); const handleDragEnd = useCallback( - (reorderedEntries: SidebarAgentListEntry[]) => { + (reorderedRows: SidebarTreeRow[]) => { if (!serverId) { - return; + return } - const reorderedKeys = reorderedEntries.map( - (entry) => `${entry.agent.serverId}:${entry.agent.id}` - ); - const currentOrder = getSidebarOrder(serverId); - const currentVisible = currentOrder.filter((key) => - reorderedKeys.includes(key) - ); - const orderChanged = - currentVisible.length !== reorderedKeys.length || - reorderedKeys.some((key, i) => currentVisible[i] !== key); - if (!orderChanged) { - return; + + const reorderedProjectKeys = reorderedRows + .filter( + (row): row is Extract => row.kind === 'project' + ) + .map((row) => row.project.projectKey) + + const currentProjectOrder = getProjectOrder(serverId) + if ( + hasVisibleOrderChanged({ + currentOrder: currentProjectOrder, + reorderedVisibleKeys: reorderedProjectKeys, + }) + ) { + setProjectOrder( + serverId, + mergeWithRemainder({ + currentOrder: currentProjectOrder, + reorderedVisibleKeys: reorderedProjectKeys, + }) + ) + } + + const workspaceRowsByProject = new Map() + for (const row of reorderedRows) { + if (row.kind !== 'workspace') { + continue + } + const list = workspaceRowsByProject.get(row.projectKey) ?? [] + list.push(row.workspace.workspaceKey) + workspaceRowsByProject.set(row.projectKey, list) + } + + for (const [projectKey, reorderedWorkspaceKeys] of workspaceRowsByProject.entries()) { + const currentWorkspaceOrder = getWorkspaceOrder(serverId, projectKey) + if ( + !hasVisibleOrderChanged({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }) + ) { + continue + } + + setWorkspaceOrder( + serverId, + projectKey, + mergeWithRemainder({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }) + ) } - const reorderedSet = new Set(reorderedKeys); - const remainder = currentOrder.filter((key) => !reorderedSet.has(key)); - setSidebarOrder(serverId, [...reorderedKeys, ...remainder]); }, - [getSidebarOrder, serverId, setSidebarOrder] - ); + [getProjectOrder, getWorkspaceOrder, serverId, setProjectOrder, setWorkspaceOrder] + ) return ( - {showProjectFilters ? ( - {}} - title="Filter by project" - placeholder="Search projects" - searchPlaceholder="Search projects" - desktopPlacement="bottom-start" - open={isProjectFilterOpen} - onOpenChange={onProjectFilterOpenChange} - anchorRef={projectFilterAnchorRef} - > - - [ - styles.filterOption, - !selectedProjectFilterKey && styles.filterOptionSelected, - hovered && styles.filterOptionHovered, - pressed && styles.filterOptionPressed, - ]} - onPress={handleClearProjectFilter} - > - All projects - - {projectFilterOptions.length === 0 ? ( - No projects - ) : ( - projectFilterOptions.map((option) => ( - - )) - )} - - - ) : null} - No projects yet} refreshing={isRefreshing} onRefresh={onRefresh} simultaneousGestureRef={parentGestureRef} - onDragBegin={handleListDragBegin} /> - - {isSelectionMode ? ( - - - {selectedBatchKeys.size} selected - - - - - - - - - - - ) : null} - ); + ) } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, }, - filterOptionsList: { - gap: theme.spacing[1], + list: { + flex: 1, }, - filterEmptyText: { + listContent: { + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[4], + }, + emptyText: { color: theme.colors.foregroundMuted, - textAlign: "center", - paddingVertical: theme.spacing[4], + textAlign: 'center', + marginTop: theme.spacing[8], + marginHorizontal: theme.spacing[2], }, - filterOption: { + projectRow: { + minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], - borderRadius: 0, - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[1], + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', gap: theme.spacing[2], }, - filterOptionHovered: { + projectRowHovered: { backgroundColor: theme.colors.surface1, }, - filterOptionPressed: { + projectRowPressed: { backgroundColor: theme.colors.surface2, }, - filterOptionSelected: { - backgroundColor: theme.colors.surface2, - }, - filterOptionLeft: { - flexDirection: "row", - alignItems: "center", + projectRowLeft: { + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[2], flex: 1, minWidth: 0, }, - filterOptionRight: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - flexShrink: 0, - }, - filterOptionLabel: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - flex: 1, - }, - filterOptionCount: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, projectIcon: { width: theme.iconSize.sm, height: theme.iconSize.sm, @@ -922,174 +575,59 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, - alignItems: "center", - justifyContent: "center", - }, - projectIconFallbackInactive: { - opacity: 0.5, + alignItems: 'center', + justifyContent: 'center', }, projectIconFallbackText: { color: theme.colors.foregroundMuted, fontSize: 9, }, - list: { - flex: 1, + projectStatusDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, }, - listContent: { - paddingHorizontal: theme.spacing[2], - paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[4], - }, - listContentSelectionMode: { - paddingBottom: theme.spacing[16], - }, - agentItem: { - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - marginBottom: theme.spacing[1], - }, - agentItemSelected: { - backgroundColor: theme.colors.surface2, - }, - agentItemHovered: { - backgroundColor: theme.colors.surface1, - }, - agentItemPressed: { - backgroundColor: theme.colors.surface2, - }, - agentRowTop: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minHeight: 20, - }, - selectionOverlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - borderRadius: theme.borderRadius.lg, - borderWidth: 2, - borderColor: theme.colors.accent, - backgroundColor: `${theme.colors.accent}1F`, - alignItems: "center", - justifyContent: "center", - }, - agentTitle: { - flex: 1, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.normal, + projectTitle: { color: theme.colors.foreground, - opacity: 1, + fontSize: theme.fontSize.sm, + flex: 1, + minWidth: 0, }, - agentTitleInactive: { - opacity: 0.8, - }, - agentTitleSelected: { - opacity: 1, - }, - relativeTimeText: { + projectCountText: { color: theme.colors.foregroundMuted, - opacity: 0.78, - fontSize: theme.fontSize.xs, - }, - shortcutBadge: { - minWidth: 20, - height: 20, - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[1], - }, - shortcutBadgeText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - archiveButton: { - minWidth: 24, - height: 20, - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[1], - }, - agentMetaRow: { - marginTop: theme.spacing[1], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - agentMetaProjectIcon: { - width: theme.iconSize.sm, - height: theme.iconSize.sm, - borderRadius: theme.borderRadius.sm, - }, - agentMetaProjectIconInactive: { - opacity: 0.5, - }, - agentMetaProjectName: { - color: theme.colors.foreground, - opacity: 0.78, fontSize: theme.fontSize.xs, flexShrink: 0, }, - agentMetaBranchBadge: { - borderRadius: theme.borderRadius.full, + workspaceRow: { + minHeight: 32, + marginBottom: theme.spacing[1], + marginLeft: theme.spacing[6], + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, - paddingHorizontal: theme.spacing[1], - paddingVertical: 1, - flexShrink: 1, - minWidth: 0, - }, - agentMetaBranchBadgeSelected: { - borderColor: theme.colors.borderAccent, - }, - agentMetaBranchText: { - color: theme.colors.foreground, - opacity: 0.76, - fontSize: theme.fontSize.xs, - flexShrink: 1, - minWidth: 0, - }, - selectionBar: { - position: "absolute", - left: theme.spacing[2], - right: theme.spacing[2], - bottom: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], - backgroundColor: theme.colors.accent, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - }, - selectionBarCountText: { - color: theme.colors.accentForeground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - selectionBarActions: { - flexDirection: "row", - alignItems: "center", + backgroundColor: theme.colors.surface0, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', gap: theme.spacing[2], }, - selectionBarButton: { - width: 36, - height: 36, - borderRadius: theme.borderRadius.full, - backgroundColor: "rgba(0, 0, 0, 0.15)", - alignItems: "center", - justifyContent: "center", + workspaceRowHovered: { + backgroundColor: theme.colors.surface1, }, - selectionBarButtonDisabled: { - opacity: 0.5, + workspaceRowPressed: { + backgroundColor: theme.colors.surface2, }, -})); + workspaceBranchText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.xs, + flex: 1, + minWidth: 0, + }, + workspaceCreatedAtText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + flexShrink: 0, + }, +})) diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 6483ca470..c59b1eb42 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -44,6 +44,10 @@ import TerminalEmulator from "./terminal-emulator"; interface TerminalPaneProps { serverId: string; cwd: string; + selectedTerminalId?: string | null; + onSelectedTerminalIdChange?: (terminalId: string | null) => void; + hideHeader?: boolean; + manageTerminalDirectorySubscription?: boolean; } const MAX_OUTPUT_CHARS = 200_000; @@ -130,7 +134,14 @@ function TerminalCloseGradient({ color, gradientId }: { color: string; gradientI ); } -export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { +export function TerminalPane({ + serverId, + cwd, + selectedTerminalId: selectedTerminalIdProp, + onSelectedTerminalIdChange, + hideHeader = false, + manageTerminalDirectorySubscription = true, +}: TerminalPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -145,8 +156,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { const streamControllerRef = useRef(null); const outputPumpRef = useRef(null); const outputDeliveryQueueRef = useRef(null); + const isSelectedTerminalControlled = + typeof selectedTerminalIdProp !== "undefined"; - const [selectedTerminalId, setSelectedTerminalId] = useState(null); + const [uncontrolledSelectedTerminalId, setUncontrolledSelectedTerminalId] = + useState(null); + const selectedTerminalId = isSelectedTerminalControlled + ? selectedTerminalIdProp ?? null + : uncontrolledSelectedTerminalId; const [selectedOutputChunk, setSelectedOutputChunk] = useState({ sequence: 0, text: "", @@ -182,9 +199,13 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { ? (next as (value: string | null) => string | null)(current) : next; selectedTerminalIdRef.current = resolved; - setSelectedTerminalId(resolved); + if (isSelectedTerminalControlled) { + onSelectedTerminalIdChange?.(resolved); + return; + } + setUncontrolledSelectedTerminalId(resolved); }, - [] + [isSelectedTerminalControlled, onSelectedTerminalIdChange] ); useEffect(() => { @@ -344,7 +365,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }, [client, isConnected, queryClient, terminalsQueryKey]); useEffect(() => { - if (!client || !isConnected || !cwd.startsWith("/")) { + if ( + !manageTerminalDirectorySubscription || + !client || + !isConnected || + !cwd.startsWith("/") + ) { return; } @@ -370,7 +396,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { unsubscribe(); client.unsubscribeTerminals({ cwd }); }; - }, [client, cwd, isConnected, queryClient, terminalsQueryKey]); + }, [ + client, + cwd, + isConnected, + manageTerminalDirectorySubscription, + queryClient, + terminalsQueryKey, + ]); const createTerminalMutation = useMutation({ mutationFn: async () => { @@ -394,7 +427,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`, }; }); - selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id); + if (!isSelectedTerminalControlled) { + selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id); + } updateSelectedTerminalId(createdTerminal.id); requestTerminalFocus(); } @@ -435,15 +470,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { }); useEffect(() => { - updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null); + if (!isSelectedTerminalControlled) { + updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null); + } lastReportedSizeRef.current = null; - }, [scopeKey, updateSelectedTerminalId]); + }, [isSelectedTerminalControlled, scopeKey, updateSelectedTerminalId]); useEffect(() => { + if (isSelectedTerminalControlled) { + return; + } if (selectedTerminalId) { selectedTerminalByScopeRef.current.set(scopeKey, selectedTerminalId); } - }, [scopeKey, selectedTerminalId]); + }, [isSelectedTerminalControlled, scopeKey, selectedTerminalId]); useEffect(() => { if (terminals.length === 0) { @@ -458,7 +498,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return; } - const stored = selectedTerminalByScopeRef.current.get(scopeKey); + const stored = isSelectedTerminalControlled + ? null + : selectedTerminalByScopeRef.current.get(scopeKey); if (has(stored)) { updateSelectedTerminalId(stored!); return; @@ -466,10 +508,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { const fallback = terminals[0]?.id ?? null; if (fallback) { - selectedTerminalByScopeRef.current.set(scopeKey, fallback); + if (!isSelectedTerminalControlled) { + selectedTerminalByScopeRef.current.set(scopeKey, fallback); + } updateSelectedTerminalId(fallback); } - }, [scopeKey, terminals, selectedTerminalId, updateSelectedTerminalId]); + }, [ + isSelectedTerminalControlled, + scopeKey, + terminals, + selectedTerminalId, + updateSelectedTerminalId, + ]); useEffect(() => { const terminalIds = terminals.map((terminal) => terminal.id); @@ -911,108 +961,110 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { return ( - - - {terminals.map((terminal) => { - const isActive = terminal.id === selectedTerminalId; - const isTabHovered = hoveredTerminalId === terminal.id; - const isCloseHovered = hoveredCloseTerminalId === terminal.id; - const isClosingTerminal = - killTerminalMutation.isPending && - killTerminalMutation.variables === terminal.id; - const shouldShowCloseButton = - isTabHovered || isCloseHovered || isClosingTerminal; - const gradientId = `terminal-close-gradient-${terminal.id.replace( - /[^a-zA-Z0-9_-]/g, - "-" - )}`; - return ( - updateSelectedTerminalId(terminal.id)} - onHoverIn={() => handleTerminalTabHoverIn(terminal.id)} - onHoverOut={() => handleTerminalTabHoverOut(terminal.id)} - style={({ pressed, hovered }) => [ - styles.terminalTab, - isActive && styles.terminalTabActive, - shouldShowCloseButton && styles.terminalTabHovered, - (pressed || hovered) && styles.terminalTabHovered, - ]} - > - - {terminal.name} - + {!hideHeader ? ( + + + {terminals.map((terminal) => { + const isActive = terminal.id === selectedTerminalId; + const isTabHovered = hoveredTerminalId === terminal.id; + const isCloseHovered = hoveredCloseTerminalId === terminal.id; + const isClosingTerminal = + killTerminalMutation.isPending && + killTerminalMutation.variables === terminal.id; + const shouldShowCloseButton = + isTabHovered || isCloseHovered || isClosingTerminal; + const gradientId = `terminal-close-gradient-${terminal.id.replace( + /[^a-zA-Z0-9_-]/g, + "-" + )}`; + return ( handleTerminalCloseHoverIn(terminal.id)} - onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)} - onPress={(event) => { - event.stopPropagation(); - void handleCloseTerminal(terminal.id); - }} - style={({ hovered, pressed }) => [ - styles.terminalTabCloseButton, - shouldShowCloseButton - ? styles.terminalTabCloseButtonShown - : styles.terminalTabCloseButtonHidden, + key={terminal.id} + testID={`terminal-tab-${terminal.id}`} + onPress={() => updateSelectedTerminalId(terminal.id)} + onHoverIn={() => handleTerminalTabHoverIn(terminal.id)} + onHoverOut={() => handleTerminalTabHoverOut(terminal.id)} + style={({ pressed, hovered }) => [ + styles.terminalTab, + isActive && styles.terminalTabActive, + shouldShowCloseButton && styles.terminalTabHovered, + (pressed || hovered) && styles.terminalTabHovered, ]} > - {({ hovered = false, pressed = false }) => { - const iconColor = - hovered || pressed - ? theme.colors.foreground - : theme.colors.foregroundMuted; - return ( - <> - - - {isClosingTerminal ? ( - - ) : ( - - )} - - - ); - }} + + {terminal.name} + + handleTerminalCloseHoverIn(terminal.id)} + onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)} + onPress={(event) => { + event.stopPropagation(); + void handleCloseTerminal(terminal.id); + }} + style={({ hovered, pressed }) => [ + styles.terminalTabCloseButton, + shouldShowCloseButton + ? styles.terminalTabCloseButtonShown + : styles.terminalTabCloseButtonHidden, + ]} + > + {({ hovered = false, pressed = false }) => { + const iconColor = + hovered || pressed + ? theme.colors.foreground + : theme.colors.foregroundMuted; + return ( + <> + + + {isClosingTerminal ? ( + + ) : ( + + )} + + + ); + }} + - - ); - })} - - - [ - styles.headerIconButton, - (hovered || pressed) && styles.headerIconButtonHovered, - ]} - > - {isCreating ? ( - - ) : ( - - )} - + ); + })} + + + [ + styles.headerIconButton, + (hovered || pressed) && styles.headerIconButtonHovered, + ]} + > + {isCreating ? ( + + ) : ( + + )} + + - + ) : null} {selectedTerminal ? ( diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 69d4398e1..dd872e031 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,6 +1,6 @@ import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; import { AppState, Platform } from "react-native"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { useAudioPlayer } from "@/hooks/use-audio-player"; import { useClientActivity } from "@/hooks/use-client-activity"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; @@ -162,27 +162,6 @@ export function clearPendingAgentUpdates(serverId: string): void { } } -const createExplorerState = () => ({ - directories: new Map(), - files: new Map(), - isLoading: false, - lastError: null, - pendingRequest: null, - currentPath: ".", - history: ["."], - lastVisitedPath: ".", - selectedEntryPath: null, -}); - -const pushHistory = (history: string[], path: string): string[] => { - const normalizedHistory = history.length === 0 ? ["."] : history; - const last = normalizedHistory[normalizedHistory.length - 1]; - if (last === path) { - return normalizedHistory; - } - return [...normalizedHistory, path]; -}; - interface SessionProviderSharedProps { children: ReactNode; serverId: string; @@ -264,7 +243,6 @@ function SessionProviderInternal({ const setPendingPermissions = useSessionStore( (state) => state.setPendingPermissions ); - const setFileExplorer = useSessionStore((state) => state.setFileExplorer); const clearDraftInput = useDraftStore((state) => state.clearDraftInput); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const updateSessionClient = useSessionStore((state) => state.updateSessionClient); @@ -605,81 +583,6 @@ function SessionProviderInternal({ ] ); - const updateExplorerState = useCallback( - (agentId: string, updater: (state: any) => any) => { - setFileExplorer(serverId, (prev) => { - const next = new Map(prev); - const current = next.get(agentId) ?? createExplorerState(); - next.set(agentId, updater(current)); - return next; - }); - }, - [serverId, setFileExplorer] - ); - - const directoryListingMutation = useMutation({ - mutationFn: async ({ agentId, path }: { agentId: string; path: string }) => { - if (!agentId) { - throw new Error("Agent id is required"); - } - if (!client) { - throw new Error("Daemon client unavailable"); - } - const resolvedPath = path && path.length > 0 ? path : "."; - const payload = await client.exploreFileSystem( - agentId, - resolvedPath, - "list" - ); - if (payload.error) { - throw new Error(payload.error); - } - return payload; - }, - }); - - const filePreviewMutation = useMutation({ - mutationFn: async ({ agentId, path }: { agentId: string; path: string }) => { - if (!agentId) { - throw new Error("Agent id is required"); - } - if (!path) { - throw new Error("File path is required"); - } - if (!client) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.exploreFileSystem( - agentId, - path, - "file" - ); - if (payload.error) { - throw new Error(payload.error); - } - return payload; - }, - }); - - const fileDownloadTokenMutation = useMutation({ - mutationFn: async ({ agentId, path }: { agentId: string; path: string }) => { - if (!agentId) { - throw new Error("Agent id is required"); - } - if (!path) { - throw new Error("File path is required"); - } - if (!client) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.requestDownloadToken(agentId, path); - if (payload.error) { - throw new Error(payload.error); - } - return payload; - }, - }); - const requestCanonicalCatchUp = useCallback( (agentId: string, cursor: { epoch: string; endSeq: number }) => { void client @@ -1356,15 +1259,6 @@ function SessionProviderInternal({ next.delete(agentId); return next; }); - - setFileExplorer(serverId, (prev) => { - if (!prev.has(agentId)) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - }); }); const unsubAgentArchived = client.on("agent_archived", (message) => { @@ -1420,7 +1314,6 @@ function SessionProviderInternal({ setAgents, setAgentLastActivity, setPendingPermissions, - setFileExplorer, setHasHydratedAgents, clearDraftInput, notifyAgentAttention, @@ -1657,167 +1550,6 @@ function SessionProviderInternal({ [] ); - const requestDirectoryListing = useCallback( - (agentId: string, path: string, options?: { recordHistory?: boolean }) => { - const normalizedPath = path && path.length > 0 ? path : "."; - const shouldRecordHistory = options?.recordHistory ?? true; - - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: true, - lastError: null, - pendingRequest: { path: normalizedPath, mode: "list" }, - currentPath: normalizedPath, - history: shouldRecordHistory - ? pushHistory(state.history, normalizedPath) - : state.history, - lastVisitedPath: normalizedPath, - })); - - directoryListingMutation - .mutateAsync({ agentId, path: normalizedPath }) - .then((payload) => { - updateExplorerState(agentId, (state: any) => { - const nextState: any = { - ...state, - isLoading: false, - lastError: payload.error ?? null, - pendingRequest: null, - directories: state.directories, - files: state.files, - }; - - if (!payload.error && payload.directory) { - const directories = new Map(state.directories); - directories.set(payload.directory.path, payload.directory); - nextState.directories = directories; - } - - return nextState; - }); - }) - .catch((error) => { - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: false, - lastError: error.message, - pendingRequest: null, - })); - }); - }, - [directoryListingMutation, updateExplorerState] - ); - - const requestFilePreview = useCallback( - (agentId: string, path: string) => { - const normalizedPath = path && path.length > 0 ? path : "."; - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: true, - pendingRequest: { path: normalizedPath, mode: "file" }, - })); - - filePreviewMutation - .mutateAsync({ agentId, path: normalizedPath }) - .then((payload) => { - updateExplorerState(agentId, (state: any) => { - const nextState: any = { - ...state, - isLoading: false, - pendingRequest: null, - directories: state.directories, - files: state.files, - }; - - if (!payload.error && payload.file) { - const files = new Map(state.files); - files.set(payload.file.path, payload.file); - nextState.files = files; - } - - return nextState; - }); - }) - .catch((error) => { - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: false, - pendingRequest: null, - })); - }); - }, - [filePreviewMutation, updateExplorerState] - ); - - const requestFileDownloadToken = useCallback( - (agentId: string, path: string) => { - return fileDownloadTokenMutation.mutateAsync({ agentId, path }); - }, - [fileDownloadTokenMutation] - ); - - const navigateExplorerBack = useCallback( - (agentId: string) => { - let targetPath: string | null = null; - - updateExplorerState(agentId, (state: any) => { - if (!state.history || state.history.length <= 1) { - return state; - } - - const nextHistory = state.history.slice(0, -1); - targetPath = nextHistory[nextHistory.length - 1] ?? "."; - - return { - ...state, - isLoading: true, - lastError: null, - pendingRequest: { path: targetPath, mode: "list" }, - currentPath: targetPath, - history: nextHistory, - lastVisitedPath: targetPath, - }; - }); - - if (!targetPath) { - return null; - } - - directoryListingMutation - .mutateAsync({ agentId, path: targetPath }) - .then((payload) => { - updateExplorerState(agentId, (state: any) => { - const nextState: any = { - ...state, - isLoading: false, - lastError: payload.error ?? null, - pendingRequest: null, - directories: state.directories, - files: state.files, - }; - - if (!payload.error && payload.directory) { - const directories = new Map(state.directories); - directories.set(payload.directory.path, payload.directory); - nextState.directories = directories; - } - - return nextState; - }); - }) - .catch((error) => { - updateExplorerState(agentId, (state: any) => ({ - ...state, - isLoading: false, - lastError: error.message, - pendingRequest: null, - })); - }); - return targetPath; - }, - [directoryListingMutation, updateExplorerState] - ); - // Cleanup on unmount useEffect(() => { return () => { diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 22501495a..5bad85628 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store"; function createExplorerState(): AgentFileExplorerState { @@ -24,34 +24,81 @@ function pushHistory(history: string[], path: string): string[] { return [...normalizedHistory, path]; } -export function useFileExplorerActions(serverId: string) { +export interface FileExplorerWorkspaceScope { + workspaceId?: string | null; + workspaceRoot?: string | null; +} + +function normalizeWorkspaceValue(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function buildWorkspaceExplorerStateKey( + scope: FileExplorerWorkspaceScope +): string | null { + const normalizedWorkspaceId = normalizeWorkspaceValue(scope.workspaceId); + if (normalizedWorkspaceId) { + return `workspace:${normalizedWorkspaceId}`; + } + const normalizedWorkspaceRoot = normalizeWorkspaceValue(scope.workspaceRoot); + if (!normalizedWorkspaceRoot) { + return null; + } + return `root:${normalizedWorkspaceRoot}`; +} + +export function useFileExplorerActions( + params: { serverId: string } & FileExplorerWorkspaceScope +) { + const { serverId, workspaceId, workspaceRoot } = params; const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const setFileExplorer = useSessionStore((state) => state.setFileExplorer); + const normalizedWorkspaceRoot = useMemo( + () => normalizeWorkspaceValue(workspaceRoot), + [workspaceRoot] + ); + const workspaceStateKey = useMemo( + () => + buildWorkspaceExplorerStateKey({ + workspaceId, + workspaceRoot: normalizedWorkspaceRoot, + }), + [workspaceId, normalizedWorkspaceRoot] + ); const updateExplorerState = useCallback( - (agentId: string, updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => { + (updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => { + if (!workspaceStateKey) { + return; + } setFileExplorer(serverId, (prev) => { const next = new Map(prev); - const current = next.get(agentId) ?? createExplorerState(); - next.set(agentId, updater(current)); + const current = next.get(workspaceStateKey) ?? createExplorerState(); + next.set(workspaceStateKey, updater(current)); return next; }); }, - [serverId, setFileExplorer] + [serverId, setFileExplorer, workspaceStateKey] ); const requestDirectoryListing = useCallback( async ( - agentId: string, path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean } ) => { + if (!workspaceStateKey) { + return; + } const normalizedPath = path && path.length > 0 ? path : "."; const shouldSetCurrentPath = options?.setCurrentPath ?? true; const shouldRecordHistory = options?.recordHistory ?? (shouldSetCurrentPath ? true : false); - updateExplorerState(agentId, (state) => ({ + updateExplorerState((state) => ({ ...state, isLoading: true, lastError: null, @@ -67,8 +114,18 @@ export function useFileExplorerActions(serverId: string) { : {}), })); + if (!normalizedWorkspaceRoot) { + updateExplorerState((state) => ({ + ...state, + isLoading: false, + lastError: "Workspace is unavailable", + pendingRequest: null, + })); + return; + } + if (!client) { - updateExplorerState(agentId, (state) => ({ + updateExplorerState((state) => ({ ...state, isLoading: false, lastError: "Host is not connected", @@ -78,8 +135,12 @@ export function useFileExplorerActions(serverId: string) { } try { - const payload = await client.exploreFileSystem(agentId, normalizedPath, "list"); - updateExplorerState(agentId, (state) => { + const payload = await client.exploreFileSystem( + normalizedWorkspaceRoot, + normalizedPath, + "list" + ); + updateExplorerState((state) => { const nextState: AgentFileExplorerState = { ...state, isLoading: false, @@ -98,7 +159,7 @@ export function useFileExplorerActions(serverId: string) { return nextState; }); } catch (error) { - updateExplorerState(agentId, (state) => ({ + updateExplorerState((state) => ({ ...state, isLoading: false, lastError: error instanceof Error ? error.message : "Failed to list directory", @@ -106,21 +167,34 @@ export function useFileExplorerActions(serverId: string) { })); } }, - [client, updateExplorerState] + [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey] ); const requestFilePreview = useCallback( - async (agentId: string, path: string) => { + async (path: string) => { + if (!workspaceStateKey) { + return; + } const normalizedPath = path && path.length > 0 ? path : "."; - updateExplorerState(agentId, (state) => ({ + updateExplorerState((state) => ({ ...state, isLoading: true, lastError: null, pendingRequest: { path: normalizedPath, mode: "file" }, })); + if (!normalizedWorkspaceRoot) { + updateExplorerState((state) => ({ + ...state, + isLoading: false, + lastError: "Workspace is unavailable", + pendingRequest: null, + })); + return; + } + if (!client) { - updateExplorerState(agentId, (state) => ({ + updateExplorerState((state) => ({ ...state, isLoading: false, lastError: "Host is not connected", @@ -130,8 +204,12 @@ export function useFileExplorerActions(serverId: string) { } try { - const payload = await client.exploreFileSystem(agentId, normalizedPath, "file"); - updateExplorerState(agentId, (state) => { + const payload = await client.exploreFileSystem( + normalizedWorkspaceRoot, + normalizedPath, + "file" + ); + updateExplorerState((state) => { const nextState: AgentFileExplorerState = { ...state, isLoading: false, @@ -151,33 +229,36 @@ export function useFileExplorerActions(serverId: string) { return nextState; }); } catch { - updateExplorerState(agentId, (state) => ({ + updateExplorerState((state) => ({ ...state, isLoading: false, pendingRequest: null, })); } }, - [client, updateExplorerState] + [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey] ); const requestFileDownloadToken = useCallback( - async (agentId: string, path: string) => { + async (path: string) => { + if (!normalizedWorkspaceRoot) { + throw new Error("Workspace is unavailable"); + } if (!client) { throw new Error("Host is not connected"); } - const payload = await client.requestDownloadToken(agentId, path); + const payload = await client.requestDownloadToken(normalizedWorkspaceRoot, path); if (payload.error) { throw new Error(payload.error); } return payload; }, - [client] + [client, normalizedWorkspaceRoot] ); const selectExplorerEntry = useCallback( - (agentId: string, path: string | null) => { - updateExplorerState(agentId, (state) => ({ + (path: string | null) => { + updateExplorerState((state) => ({ ...state, selectedEntryPath: path, })); @@ -186,6 +267,7 @@ export function useFileExplorerActions(serverId: string) { ); return { + workspaceStateKey, requestDirectoryListing, requestFilePreview, requestFileDownloadToken, diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index a45ce87e1..c60c33c78 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -47,7 +47,11 @@ export function useKeyboardShortcuts({ toggleFileExplorer?: () => void; }) { const router = useRouter(); - const pathname = usePathname(); + const routerPathname = usePathname(); + const pathname = + Platform.OS === "web" && typeof window !== "undefined" + ? window.location.pathname + : routerPathname; const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); useEffect(() => { @@ -285,6 +289,7 @@ export function useKeyboardShortcuts({ pathname, resetModifiers, router, + routerPathname, selectedAgentId, toggleAgentList, toggleFileExplorer, diff --git a/packages/app/src/hooks/use-sidebar-agents-list.test.ts b/packages/app/src/hooks/use-sidebar-agents-list.test.ts index 688f69568..41d1e29f3 100644 --- a/packages/app/src/hooks/use-sidebar-agents-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-agents-list.test.ts @@ -1,189 +1,43 @@ -import { describe, expect, it } from "vitest"; -import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; -import { - applySidebarUserOrdering, - type SidebarAgentListEntry, -} from "./use-sidebar-agents-list"; +import { describe, expect, it } from 'vitest' +import { applyStoredOrdering } from './use-sidebar-agents-list' -function toKey(entry: SidebarAgentListEntry): string { - return `${entry.agent.serverId}:${entry.agent.id}`; +interface OrderedItem { + key: string } -function createEntry(input: { - id: string; - createdAt: string; - status?: AgentLifecycleStatus; - serverId?: string; -}): SidebarAgentListEntry { - const serverId = input.serverId ?? "server"; - const createdAt = new Date(input.createdAt); - return { - agent: { - id: input.id, - serverId, - serverLabel: serverId, - title: input.id, - status: input.status ?? "idle", - createdAt, - lastActivityAt: createdAt, - cwd: "/tmp/project", - provider: "codex", - pendingPermissionCount: 0, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - archivedAt: null, - labels: { ui: "true" }, - }, - project: { - projectKey: "project", - projectName: "Project", - checkout: { - cwd: "/tmp/project", - isGit: false, - currentBranch: null, - remoteUrl: null, - isPaseoOwnedWorktree: false, - mainRepoRoot: null, - }, - }, - }; +function item(key: string): OrderedItem { + return { key } } -describe("applySidebarUserOrdering", () => { - it("keeps unknown entries on the chronological baseline", () => { - const entries = [ - createEntry({ id: "a", createdAt: "2026-03-01T10:00:00.000Z" }), - createEntry({ id: "b", createdAt: "2026-02-28T10:00:00.000Z" }), - createEntry({ id: "x", createdAt: "2026-02-01T10:00:00.000Z" }), - ]; - const result = applySidebarUserOrdering({ - entries, - order: ["server:b", "server:a"], - }); +describe('applyStoredOrdering', () => { + it('keeps unknown items on the baseline while applying stored order', () => { + const result = applyStoredOrdering({ + items: [item('new'), item('a'), item('b')], + storedOrder: ['b', 'a'], + getKey: (entry) => entry.key, + }) - expect(result.entries.map(toKey)).toEqual(["server:b", "server:a", "server:x"]); - }); + expect(result.map((entry) => entry.key)).toEqual(['new', 'b', 'a']) + }) - it("places new entries first while preserving persisted order for known entries", () => { - const entries = [ - createEntry({ id: "new", createdAt: "2026-03-02T10:00:00.000Z" }), - createEntry({ id: "a", createdAt: "2026-03-01T10:00:00.000Z" }), - createEntry({ id: "b", createdAt: "2026-02-28T10:00:00.000Z" }), - ]; - const result = applySidebarUserOrdering({ - entries, - order: ["server:b", "server:a"], - }); + it('ignores stale and duplicate stored keys', () => { + const result = applyStoredOrdering({ + items: [item('x'), item('y')], + storedOrder: ['missing', 'y', 'y', 'x'], + getKey: (entry) => entry.key, + }) - expect(result.entries.map(toKey)).toEqual([ - "server:new", - "server:b", - "server:a", - ]); - }); + expect(result.map((entry) => entry.key)).toEqual(['y', 'x']) + }) - it("keeps persisted placement stable across status changes", () => { - const baseEntries = [ - createEntry({ - id: "a", - createdAt: "2026-03-01T10:00:00.000Z", - status: "running", - }), - createEntry({ - id: "b", - createdAt: "2026-02-28T10:00:00.000Z", - status: "idle", - }), - createEntry({ - id: "c", - createdAt: "2026-02-27T10:00:00.000Z", - status: "idle", - }), - ]; - const order = ["server:c", "server:a", "server:b"]; + it('returns baseline when there is no persisted order', () => { + const baseline = [item('first'), item('second')] + const result = applyStoredOrdering({ + items: baseline, + storedOrder: [], + getKey: (entry) => entry.key, + }) - const initial = applySidebarUserOrdering({ entries: baseEntries, order }); - expect(initial.entries.map(toKey)).toEqual(order); - - const afterStatusChange = applySidebarUserOrdering({ - entries: baseEntries.map((entry) => - entry.agent.id === "a" - ? { - ...entry, - agent: { - ...entry.agent, - status: "idle", - }, - } - : entry - ), - order, - }); - - expect(afterStatusChange.entries.map(toKey)).toEqual(order); - }); - - it("always includes active entries while capping done entries to 50", () => { - const doneEntries = Array.from({ length: 55 }, (_, index) => - createEntry({ - id: `done-${index + 1}`, - createdAt: new Date( - Date.UTC(2026, 2, 1, 12, 0, 0) - index * 60_000 - ).toISOString(), - status: "idle", - }) - ); - const activeEntry = createEntry({ - id: "active-oldest", - createdAt: "2026-01-01T10:00:00.000Z", - status: "running", - }); - - const result = applySidebarUserOrdering({ - entries: [...doneEntries, activeEntry], - order: [], - }); - - expect(result.entries).toHaveLength(51); - expect(result.entries.some((entry) => entry.agent.id === "active-oldest")).toBe( - true - ); - expect(result.entries.filter((entry) => entry.agent.status === "idle")).toHaveLength( - 50 - ); - expect(result.hasMore).toBe(true); - }); - - it("prioritizes newest done entries for the 50-item done window", () => { - const oldDone = Array.from({ length: 55 }, (_, index) => - createEntry({ - id: `old-${index + 1}`, - createdAt: new Date( - Date.UTC(2026, 1, 1, 12, 0, 0) - index * 60_000 - ).toISOString(), - status: "idle", - }) - ); - const recentDone = [ - createEntry({ id: "recent-1", createdAt: "2026-03-01T12:00:00.000Z" }), - createEntry({ id: "recent-2", createdAt: "2026-03-01T11:59:00.000Z" }), - createEntry({ id: "recent-3", createdAt: "2026-03-01T11:58:00.000Z" }), - ]; - const entries = [...oldDone, ...recentDone]; - const pollutedOrder = [...oldDone, ...recentDone].map(toKey); - - const result = applySidebarUserOrdering({ - entries, - order: pollutedOrder, - }); - const visibleKeys = result.entries.map(toKey); - - expect(result.entries).toHaveLength(50); - expect(visibleKeys).toContain("server:recent-1"); - expect(visibleKeys).toContain("server:recent-2"); - expect(visibleKeys).toContain("server:recent-3"); - expect(visibleKeys).not.toContain("server:old-55"); - expect(result.hasMore).toBe(true); - }); -}); + expect(result).toBe(baseline) + }) +}) diff --git a/packages/app/src/hooks/use-sidebar-agents-list.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts index a36588024..75e65120c 100644 --- a/packages/app/src/hooks/use-sidebar-agents-list.ts +++ b/packages/app/src/hooks/use-sidebar-agents-list.ts @@ -1,378 +1,642 @@ -import { useCallback, useMemo, useSyncExternalStore } from "react"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import { useSessionStore, type Agent } from "@/stores/session-store"; -import { - getHostRuntimeStore, -} from "@/runtime/host-runtime"; -import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { - isSidebarActiveAgent, -} from "@/utils/sidebar-agent-state"; -import type { ProjectPlacementPayload } from "@server/shared/messages"; -import { resolveProjectPlacement } from "@/utils/project-placement"; -import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { useCallback, useMemo, useSyncExternalStore } from 'react' +import { useQueries } from '@tanstack/react-query' +import { useSessionStore } from '@/stores/session-store' +import { getHostRuntimeStore } from '@/runtime/host-runtime' +import { resolveProjectPlacement } from '@/utils/project-placement' +import { deriveSidebarStateBucket, type SidebarStateBucket } from '@/utils/sidebar-agent-state' +import { useSidebarOrderStore } from '@/stores/sidebar-order-store' +import { checkoutStatusQueryKey } from '@/hooks/use-checkout-status-query' -const SIDEBAR_DONE_FILL_TARGET = 50; -const EMPTY_ORDER: string[] = []; +const EMPTY_ORDER: string[] = [] +const EMPTY_PROJECTS: SidebarProjectEntry[] = [] -export interface SidebarProjectFilterOption { - projectKey: string; - projectName: string; - activeCount: number; - totalCount: number; - serverId: string; - workingDir: string; +interface PaseoWorktreeEntry { + worktreePath: string + createdAt: string + branchName?: string | null + head?: string | null } -export interface SidebarAgentListEntry { - agent: AggregatedAgent & { createdAt: Date }; - project: ProjectPlacementPayload; +export interface SidebarWorkspaceEntry { + workspaceKey: string + serverId: string + cwd: string + branchName: string | null + createdAt: Date | null + isMainCheckout: boolean + isPaseoOwnedWorktree: boolean +} + +export interface SidebarProjectEntry { + projectKey: string + projectName: string + iconWorkingDir: string + statusBucket: SidebarStateBucket + activeCount: number + totalCount: number + latestCreatedAt: Date | null + workspaces: SidebarWorkspaceEntry[] } export interface SidebarAgentsListResult { - entries: SidebarAgentListEntry[]; - projectFilterOptions: SidebarProjectFilterOption[]; - hasMoreEntries: boolean; - isLoading: boolean; - isInitialLoad: boolean; - isRevalidating: boolean; - refreshAll: () => void; + projects: SidebarProjectEntry[] + isLoading: boolean + isInitialLoad: boolean + isRevalidating: boolean + refreshAll: () => void } -function compareByCreatedAtDesc( - left: SidebarAgentListEntry, - right: SidebarAgentListEntry +interface MutableWorkspaceEntry { + workspaceKey: string + serverId: string + cwd: string + branchName: string | null + createdAt: Date | null + isMainCheckout: boolean + isPaseoOwnedWorktree: boolean +} + +interface MutableProjectEntry { + projectKey: string + projectName: string + iconWorkingDir: string + statusBucket: SidebarStateBucket + activeCount: number + totalCount: number + latestCreatedAt: Date | null + repoRoots: Set + workspacesByKey: Map +} + +interface WorktreeRequest { + requestKey: string + serverId: string + projectKey: string + repoRoot: string +} + +const SIDEBAR_BUCKET_PRIORITY: Record = { + done: 0, + attention: 1, + running: 2, + failed: 3, + needs_input: 4, +} + +function normalizePath(value: string | null | undefined): string { + if (typeof value !== 'string') { + return '' + } + return value.trim() +} + +function toWorkspaceKey(serverId: string, cwd: string): string { + return `${serverId}:${cwd}` +} + +function coerceBranchName(value: string | null | undefined): string | null { + const trimmed = normalizePath(value) + return trimmed.length > 0 ? trimmed : null +} + +function maxDate(left: Date | null, right: Date): Date { + if (!left || right.getTime() > left.getTime()) { + return right + } + return left +} + +function minDate(left: Date | null, right: Date): Date { + if (!left || right.getTime() < left.getTime()) { + return right + } + return left +} + +function aggregateBucket( + current: SidebarStateBucket, + candidate: SidebarStateBucket +): SidebarStateBucket { + return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current] ? candidate : current +} + +function compareWorkspaceBaseline( + left: SidebarWorkspaceEntry, + right: SidebarWorkspaceEntry ): number { - const createdDelta = right.agent.createdAt.getTime() - left.agent.createdAt.getTime(); - if (createdDelta !== 0) { - return createdDelta; + if (left.isMainCheckout !== right.isMainCheckout) { + return left.isMainCheckout ? -1 : 1 } - const leftTitle = (left.agent.title?.trim() || "New agent").toLocaleLowerCase(); - const rightTitle = (right.agent.title?.trim() || "New agent").toLocaleLowerCase(); - const titleDelta = leftTitle.localeCompare(rightTitle, undefined, { - numeric: true, - sensitivity: "base", - }); - if (titleDelta !== 0) { - return titleDelta; - } - - return left.agent.id.localeCompare(right.agent.id, undefined, { - numeric: true, - sensitivity: "base", - }); -} - -function toSidebarAgentKey(entry: SidebarAgentListEntry): string { - return `${entry.agent.serverId}:${entry.agent.id}`; -} - -export function applySidebarUserOrdering(input: { - entries: SidebarAgentListEntry[]; - order: string[]; -}): { entries: SidebarAgentListEntry[]; hasMore: boolean } { - const sortedByCreatedAt = [...input.entries].sort(compareByCreatedAtDesc); - const entryByKey = new Map(); - for (const entry of sortedByCreatedAt) { - entryByKey.set(toSidebarAgentKey(entry), entry); - } - - const prunedOrder: string[] = []; - const seenOrderKeys = new Set(); - for (const key of input.order) { - if (!entryByKey.has(key) || seenOrderKeys.has(key)) { - continue; + if (left.createdAt && right.createdAt) { + const dateDelta = right.createdAt.getTime() - left.createdAt.getTime() + if (dateDelta !== 0) { + return dateDelta } - seenOrderKeys.add(key); - prunedOrder.push(key); + } else if (left.createdAt || right.createdAt) { + return left.createdAt ? -1 : 1 + } + + const leftBranch = left.branchName ?? '' + const rightBranch = right.branchName ?? '' + const branchDelta = leftBranch.localeCompare(rightBranch, undefined, { + numeric: true, + sensitivity: 'base', + }) + if (branchDelta !== 0) { + return branchDelta + } + + return left.cwd.localeCompare(right.cwd, undefined, { + numeric: true, + sensitivity: 'base', + }) +} + +function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number { + if (left.latestCreatedAt && right.latestCreatedAt) { + const dateDelta = right.latestCreatedAt.getTime() - left.latestCreatedAt.getTime() + if (dateDelta !== 0) { + return dateDelta + } + } else if (left.latestCreatedAt || right.latestCreatedAt) { + return left.latestCreatedAt ? -1 : 1 + } + + return left.projectName.localeCompare(right.projectName, undefined, { + numeric: true, + sensitivity: 'base', + }) +} + +export function applyStoredOrdering(input: { + items: T[] + storedOrder: string[] + getKey: (item: T) => string +}): T[] { + if (input.items.length <= 1 || input.storedOrder.length === 0) { + return input.items + } + + const itemByKey = new Map() + for (const item of input.items) { + itemByKey.set(input.getKey(item), item) + } + + const prunedOrder: string[] = [] + const seen = new Set() + for (const key of input.storedOrder) { + if (!itemByKey.has(key) || seen.has(key)) { + continue + } + seen.add(key) + prunedOrder.push(key) } - const sorted: SidebarAgentListEntry[] = []; if (prunedOrder.length === 0) { - sorted.push(...sortedByCreatedAt); - } else { - const knownOrderSet = new Set(prunedOrder); - let knownOrderIndex = 0; - for (const entry of sortedByCreatedAt) { - const key = toSidebarAgentKey(entry); - if (!knownOrderSet.has(key)) { - sorted.push(entry); - continue; - } - const orderedKey = prunedOrder[knownOrderIndex] ?? key; - knownOrderIndex += 1; - sorted.push(entryByKey.get(orderedKey) ?? entry); - } + return input.items } - const doneEntries = sorted.filter( - (entry) => - !isSidebarActiveAgent({ - status: entry.agent.status, - pendingPermissionCount: entry.agent.pendingPermissionCount, - requiresAttention: entry.agent.requiresAttention, - attentionReason: entry.agent.attentionReason, - }) - ); - const visibleDoneKeys = new Set( - [...doneEntries] - .sort(compareByCreatedAtDesc) - .slice(0, SIDEBAR_DONE_FILL_TARGET) - .map((entry) => toSidebarAgentKey(entry)) - ); + const orderedSet = new Set(prunedOrder) + const ordered: T[] = [] + let orderedIndex = 0 - const visible: SidebarAgentListEntry[] = []; - let hiddenDoneCount = 0; - for (const entry of sorted) { - const key = toSidebarAgentKey(entry); - if ( - isSidebarActiveAgent({ - status: entry.agent.status, - pendingPermissionCount: entry.agent.pendingPermissionCount, - requiresAttention: entry.agent.requiresAttention, - attentionReason: entry.agent.attentionReason, - }) - ) { - visible.push(entry); - continue; + for (const item of input.items) { + const key = input.getKey(item) + if (!orderedSet.has(key)) { + ordered.push(item) + continue } - if (visibleDoneKeys.has(key)) { - visible.push(entry); - continue; - } - hiddenDoneCount += 1; + + const targetKey = prunedOrder[orderedIndex] ?? key + orderedIndex += 1 + ordered.push(itemByKey.get(targetKey) ?? item) } - return { - entries: visible, - hasMore: hiddenDoneCount > 0, - }; + return ordered } -function toAggregatedAgent(params: { - source: Agent; - serverId: string; - serverLabel: string; - lastActivityAt: Date; -}): AggregatedAgent & { createdAt: Date } { - const source = params.source; +function ensureWorkspace( + project: MutableProjectEntry, + input: { + serverId: string + cwd: string + branchName: string | null + createdAt: Date | null + isMainCheckout: boolean + isPaseoOwnedWorktree: boolean + } +): MutableWorkspaceEntry { + const workspaceKey = toWorkspaceKey(input.serverId, input.cwd) + const existing = project.workspacesByKey.get(workspaceKey) + + if (existing) { + if (!existing.branchName && input.branchName) { + existing.branchName = input.branchName + } + if (input.createdAt) { + existing.createdAt = existing.createdAt + ? minDate(existing.createdAt, input.createdAt) + : input.createdAt + } + if (input.isMainCheckout) { + existing.isMainCheckout = true + } + if (input.isPaseoOwnedWorktree) { + existing.isPaseoOwnedWorktree = true + } + return existing + } + + const workspace: MutableWorkspaceEntry = { + workspaceKey, + serverId: input.serverId, + cwd: input.cwd, + branchName: input.branchName, + createdAt: input.createdAt, + isMainCheckout: input.isMainCheckout, + isPaseoOwnedWorktree: input.isPaseoOwnedWorktree, + } + project.workspacesByKey.set(workspaceKey, workspace) + return workspace +} + +function cloneProject(project: MutableProjectEntry): MutableProjectEntry { return { - id: source.id, - serverId: params.serverId, - serverLabel: params.serverLabel, - title: source.title ?? null, - status: source.status, - createdAt: source.createdAt, - lastActivityAt: params.lastActivityAt, - cwd: source.cwd, - provider: source.provider, - pendingPermissionCount: source.pendingPermissions.length, - requiresAttention: source.requiresAttention, - attentionReason: source.attentionReason, - attentionTimestamp: source.attentionTimestamp ?? null, - archivedAt: source.archivedAt ?? null, - labels: source.labels, - }; + ...project, + repoRoots: new Set(project.repoRoots), + workspacesByKey: new Map( + Array.from(project.workspacesByKey.entries()).map(([key, workspace]) => [ + key, + { ...workspace }, + ]) + ), + } +} + +function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string { + return `${serverId.trim()}::${projectKey.trim()}` } export function useSidebarAgentsList(options?: { - serverId?: string | null; - selectedProjectFilterKey?: string | null; + serverId?: string | null + enabled?: boolean }): SidebarAgentsListResult { - const { daemons } = useDaemonRegistry(); - const runtime = getHostRuntimeStore(); - const daemonLabelSignature = useMemo( - () => - daemons - .map((daemon) => `${daemon.serverId}:${daemon.label ?? ""}`) - .join("|"), - [daemons] - ); + const runtime = getHostRuntimeStore() + const serverId = useMemo(() => { - const value = options?.serverId; - return typeof value === "string" && value.trim().length > 0 - ? value.trim() - : null; - }, [options?.serverId]); - const serverLabel = useMemo(() => { - if (!serverId) { - return ""; - } - return daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; - }, [daemonLabelSignature, serverId]); + const value = options?.serverId + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null + }, [options?.serverId]) + const enabled = options?.enabled ?? true - const selectedProjectFilterKey = useMemo(() => { - const value = options?.selectedProjectFilterKey; - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - }, [options?.selectedProjectFilterKey]); - const persistedOrder = useSidebarOrderStore((state) => - serverId ? state.orderByServerId[serverId] ?? EMPTY_ORDER : EMPTY_ORDER - ); + const persistedProjectOrder = useSidebarOrderStore((state) => + serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER + ) + const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) => + serverId ? state.workspaceOrderByServerAndProject : {} + ) - const isActive = Boolean(serverId); + const isActive = Boolean(serverId) const liveAgents = useSessionStore((state) => - isActive && serverId ? state.sessions[serverId]?.agents ?? null : null - ); - const agentLastActivity = useSessionStore((state) => - isActive ? state.agentLastActivity : null - ); + isActive && serverId ? (state.sessions[serverId]?.agents ?? null) : null + ) + const runtimeStatusSignature = useSyncExternalStore( (onStoreChange) => isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {}, () => { if (!isActive || !serverId) { - return "idle:idle"; + return 'idle:idle' } - const snapshot = runtime.getSnapshot(serverId); - const connectionStatus = snapshot?.connectionStatus ?? "idle"; - const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle"; - return `${connectionStatus}:${directoryStatus}`; + const snapshot = runtime.getSnapshot(serverId) + const connectionStatus = snapshot?.connectionStatus ?? 'idle' + const directoryStatus = snapshot?.agentDirectoryStatus ?? 'idle' + return `${connectionStatus}:${directoryStatus}` }, () => { if (!isActive || !serverId) { - return "idle:idle"; + return 'idle:idle' } - const snapshot = runtime.getSnapshot(serverId); - const connectionStatus = snapshot?.connectionStatus ?? "idle"; - const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle"; - return `${connectionStatus}:${directoryStatus}`; + const snapshot = runtime.getSnapshot(serverId) + const connectionStatus = snapshot?.connectionStatus ?? 'idle' + const directoryStatus = snapshot?.agentDirectoryStatus ?? 'idle' + return `${connectionStatus}:${directoryStatus}` } - ); - const [connectionStatus = "idle", directoryStatus = "idle"] = - runtimeStatusSignature.split(":", 2); + ) + const [connectionStatus = 'idle', directoryStatus = 'idle'] = runtimeStatusSignature.split(':', 2) - const { - entries, - projectFilterOptions, - hasAnyData, - hasMoreEntries, - } = useMemo(() => { + const base = useMemo(() => { if (!isActive || !serverId || !liveAgents) { return { - entries: [] as SidebarAgentListEntry[], - projectFilterOptions: [] as SidebarProjectFilterOption[], + projectsByKey: new Map(), + worktreeRequests: [] as WorktreeRequest[], hasAnyData: false, - hasMoreEntries: false, - }; + } } - const seenAgentIds = new Set(); - const byProject = new Map(); - const mergedEntries: SidebarAgentListEntry[] = []; + const projectsByKey = new Map() - const pushEntry = (entry: SidebarAgentListEntry): void => { - if (entry.agent.archivedAt) { - return; + for (const sourceAgent of liveAgents.values()) { + if (sourceAgent.archivedAt || sourceAgent.labels.ui !== 'true') { + continue } - const dedupeKey = `${entry.agent.serverId}:${entry.agent.id}`; - if (seenAgentIds.has(dedupeKey)) { - return; - } - seenAgentIds.add(dedupeKey); - mergedEntries.push(entry); - const existing = byProject.get(entry.project.projectKey); - const isActive = isSidebarActiveAgent({ - status: entry.agent.status, - pendingPermissionCount: entry.agent.pendingPermissionCount, - requiresAttention: entry.agent.requiresAttention, - attentionReason: entry.agent.attentionReason, - }); - if (existing) { - existing.totalCount += 1; - if (isActive) { - existing.activeCount += 1; + const placement = resolveProjectPlacement({ + projectPlacement: sourceAgent.projectPlacement ?? null, + cwd: sourceAgent.cwd, + }) + const projectKey = normalizePath(placement.projectKey) + if (!projectKey) { + continue + } + + const checkoutCwd = normalizePath(placement.checkout.cwd || sourceAgent.cwd) + if (!checkoutCwd) { + continue + } + + const projectName = normalizePath(placement.projectName) || projectKey + const project = + projectsByKey.get(projectKey) ?? + ({ + projectKey, + projectName, + iconWorkingDir: checkoutCwd, + statusBucket: 'done', + activeCount: 0, + totalCount: 0, + latestCreatedAt: null, + repoRoots: new Set(), + workspacesByKey: new Map(), + } satisfies MutableProjectEntry) + + project.totalCount += 1 + project.latestCreatedAt = maxDate(project.latestCreatedAt, sourceAgent.createdAt) + const bucket = deriveSidebarStateBucket({ + status: sourceAgent.status, + pendingPermissionCount: sourceAgent.pendingPermissions.length, + requiresAttention: sourceAgent.requiresAttention, + attentionReason: sourceAgent.attentionReason, + }) + project.statusBucket = aggregateBucket(project.statusBucket, bucket) + if (bucket !== 'done') { + project.activeCount += 1 + } + + const normalizedBranch = coerceBranchName(placement.checkout.currentBranch) + const isMainCheckout = placement.checkout.isGit && !placement.checkout.isPaseoOwnedWorktree + const workspace = ensureWorkspace(project, { + serverId, + cwd: checkoutCwd, + branchName: normalizedBranch, + createdAt: isMainCheckout ? sourceAgent.createdAt : null, + isMainCheckout, + isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree, + }) + + const explicitMainRepoRoot = normalizePath(placement.checkout.mainRepoRoot) + if (placement.checkout.isPaseoOwnedWorktree && explicitMainRepoRoot) { + project.repoRoots.add(explicitMainRepoRoot) + if (!project.iconWorkingDir) { + project.iconWorkingDir = explicitMainRepoRoot } - return; + } + if (isMainCheckout) { + project.repoRoots.add(workspace.cwd) + project.iconWorkingDir = workspace.cwd } - byProject.set(entry.project.projectKey, { - projectKey: entry.project.projectKey, - projectName: entry.project.projectName, - activeCount: isActive ? 1 : 0, - totalCount: 1, - serverId, - workingDir: entry.project.checkout.cwd, - }); - }; - - for (const live of liveAgents.values()) { - if (live.archivedAt || live.labels.ui !== "true") { - continue; + if (!project.iconWorkingDir) { + project.iconWorkingDir = workspace.cwd } - const project = resolveProjectPlacement({ - projectPlacement: live.projectPlacement ?? null, - cwd: live.cwd, - }); - const effectiveLastActivity = - agentLastActivity?.get(live.id) ?? live.lastActivityAt; - const agent = toAggregatedAgent({ - source: live, - serverId, - serverLabel, - lastActivityAt: effectiveLastActivity, - }); - pushEntry({ agent, project }); + + projectsByKey.set(projectKey, project) } - const filteredEntries = selectedProjectFilterKey - ? mergedEntries.filter( - (entry) => entry.project.projectKey === selectedProjectFilterKey - ) - : mergedEntries; - const ordered = applySidebarUserOrdering({ - entries: filteredEntries, - order: persistedOrder, - }); - const options = Array.from(byProject.values()).sort((left, right) => { - if (left.activeCount !== right.activeCount) { - return right.activeCount - left.activeCount; + const worktreeRequests: WorktreeRequest[] = [] + for (const project of projectsByKey.values()) { + for (const repoRoot of project.repoRoots) { + if (!repoRoot) { + continue + } + const requestKey = `${serverId}:${project.projectKey}:${repoRoot}` + worktreeRequests.push({ + requestKey, + serverId, + projectKey: project.projectKey, + repoRoot, + }) } - return left.projectName.localeCompare(right.projectName); - }); + } + + worktreeRequests.sort((left, right) => + left.requestKey.localeCompare(right.requestKey, undefined, { + numeric: true, + sensitivity: 'base', + }) + ) return { - entries: ordered.entries, - projectFilterOptions: options, - hasAnyData: mergedEntries.length > 0, - hasMoreEntries: ordered.hasMore, - }; + projectsByKey, + worktreeRequests, + hasAnyData: projectsByKey.size > 0, + } + }, [isActive, liveAgents, serverId]) + + const worktreeQueries = useQueries({ + queries: base.worktreeRequests.map((request) => ({ + queryKey: ['sidebarPaseoWorktreeList', request.serverId, request.repoRoot], + queryFn: async (): Promise => { + const client = runtime.getClient(request.serverId) + if (!client) { + return [] + } + const payload = await client.getPaseoWorktreeList({ + repoRoot: request.repoRoot, + }) + if (payload.error) { + return [] + } + return payload.worktrees ?? [] + }, + enabled: + enabled && + isActive && + Boolean(serverId) && + connectionStatus === 'online' && + Boolean(runtime.getClient(request.serverId)) && + request.repoRoot.length > 0, + staleTime: 15_000, + gcTime: 1000 * 60 * 10, + retry: false, + refetchOnMount: 'always' as const, + })), + }) + + const repoRootStatusQueries = useQueries({ + queries: base.worktreeRequests.map((request) => ({ + queryKey: checkoutStatusQueryKey(request.serverId, request.repoRoot), + queryFn: async () => { + const client = runtime.getClient(request.serverId) + if (!client) { + return null + } + try { + return await client.getCheckoutStatus(request.repoRoot) + } catch { + return null + } + }, + enabled: + enabled && + isActive && + Boolean(serverId) && + connectionStatus === 'online' && + Boolean(runtime.getClient(request.serverId)) && + request.repoRoot.length > 0, + staleTime: 15_000, + gcTime: 1000 * 60 * 10, + retry: false, + refetchOnMount: 'always' as const, + })), + }) + + const projects = useMemo(() => { + if (base.projectsByKey.size === 0) { + return EMPTY_PROJECTS + } + + const projectsByKey = new Map() + for (const [key, project] of base.projectsByKey.entries()) { + projectsByKey.set(key, cloneProject(project)) + } + + for (let i = 0; i < base.worktreeRequests.length; i += 1) { + const request = base.worktreeRequests[i] + if (!request) { + continue + } + + const project = projectsByKey.get(request.projectKey) + if (!project) { + continue + } + + const checkout = repoRootStatusQueries[i]?.data ?? null + const mainBranchName = coerceBranchName(checkout?.currentBranch) + + ensureWorkspace(project, { + serverId: request.serverId, + cwd: request.repoRoot, + branchName: mainBranchName, + createdAt: null, + isMainCheckout: true, + isPaseoOwnedWorktree: false, + }) + + const worktrees = worktreeQueries[i]?.data ?? [] + for (const worktree of worktrees) { + const worktreePath = normalizePath(worktree.worktreePath) + if (!worktreePath) { + continue + } + + const createdAt = (() => { + const createdAtRaw = worktree.createdAt + if (typeof createdAtRaw !== 'string' || createdAtRaw.trim().length === 0) { + return null + } + const parsed = new Date(createdAtRaw) + return Number.isNaN(parsed.getTime()) ? null : parsed + })() + + ensureWorkspace(project, { + serverId: request.serverId, + cwd: worktreePath, + branchName: coerceBranchName(worktree.branchName) ?? coerceBranchName(worktree.head), + createdAt, + isMainCheckout: false, + isPaseoOwnedWorktree: true, + }) + } + } + + const baselineProjects: SidebarProjectEntry[] = Array.from(projectsByKey.values()).map( + (project) => { + const baselineWorkspaces = Array.from(project.workspacesByKey.values()).map( + (workspace) => ({ + workspaceKey: workspace.workspaceKey, + serverId: workspace.serverId, + cwd: workspace.cwd, + branchName: workspace.branchName, + createdAt: workspace.createdAt, + isMainCheckout: workspace.isMainCheckout, + isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, + }) + ) + + baselineWorkspaces.sort(compareWorkspaceBaseline) + + const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId ?? '', project.projectKey) + + const orderedWorkspaces = applyStoredOrdering({ + items: baselineWorkspaces, + storedOrder: persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER, + getKey: (workspace) => workspace.workspaceKey, + }) + + return { + projectKey: project.projectKey, + projectName: project.projectName, + iconWorkingDir: project.iconWorkingDir, + statusBucket: project.statusBucket, + activeCount: project.activeCount, + totalCount: project.totalCount, + latestCreatedAt: project.latestCreatedAt, + workspaces: orderedWorkspaces, + } + } + ) + + baselineProjects.sort(compareProjectBaseline) + + return applyStoredOrdering({ + items: baselineProjects, + storedOrder: persistedProjectOrder, + getKey: (project) => project.projectKey, + }) }, [ - agentLastActivity, + base.projectsByKey, + base.worktreeRequests, + connectionStatus, isActive, - liveAgents, - persistedOrder, - selectedProjectFilterKey, + persistedProjectOrder, + persistedWorkspaceOrderByScope, + repoRootStatusQueries, serverId, - serverLabel, - ]); + worktreeQueries, + ]) const refreshAll = useCallback(() => { - if (!isActive || !serverId || connectionStatus !== "online") { - return; + if (!isActive || !serverId || connectionStatus !== 'online') { + return } - void runtime.refreshAgentDirectory({ serverId, page: { limit: 50 } }).catch(() => undefined); - }, [connectionStatus, isActive, runtime, serverId]); + void runtime.refreshAgentDirectory({ serverId, page: { limit: 50 } }).catch(() => undefined) + }, [connectionStatus, isActive, runtime, serverId]) const isDirectoryLoading = isActive && Boolean(serverId) && - (directoryStatus === "initial_loading" || directoryStatus === "revalidating"); - const isInitialLoad = isDirectoryLoading && !hasAnyData; - const isRevalidating = isDirectoryLoading && hasAnyData; + (directoryStatus === 'initial_loading' || directoryStatus === 'revalidating') + const isInitialLoad = isDirectoryLoading && !base.hasAnyData + const isRevalidating = isDirectoryLoading && base.hasAnyData return { - entries, - projectFilterOptions, - hasMoreEntries, + projects, isLoading: isDirectoryLoading, isInitialLoad, isRevalidating, refreshAll, - }; + } } diff --git a/packages/app/src/keyboard/keyboard-shortcut-routing.test.ts b/packages/app/src/keyboard/keyboard-shortcut-routing.test.ts index a73ad829b..be174e16a 100644 --- a/packages/app/src/keyboard/keyboard-shortcut-routing.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcut-routing.test.ts @@ -9,7 +9,25 @@ describe("keyboard-shortcut-routing", () => { it("allows the shortcut on selected-agent routes", () => { const canToggle = canToggleFileExplorerShortcut({ selectedAgentId: "server-1:agent-1", - pathname: "/h/server-1/agent/agent-1", + pathname: "/h/server-1/workspace/workspace-1/agent/agent-1", + toggleFileExplorer, + }); + + expect(canToggle).toBe(true); + }); + + it("allows the shortcut on workspace routes", () => { + const canToggle = canToggleFileExplorerShortcut({ + pathname: "/h/server-1/workspace/workspace-1", + toggleFileExplorer, + }); + + expect(canToggle).toBe(true); + }); + + it("allows the shortcut on workspace terminal routes", () => { + const canToggle = canToggleFileExplorerShortcut({ + pathname: "/h/server-1/workspace/workspace-1/terminal/terminal-1", toggleFileExplorer, }); @@ -18,7 +36,7 @@ describe("keyboard-shortcut-routing", () => { it("allows the shortcut on draft routes", () => { const canToggle = canToggleFileExplorerShortcut({ - pathname: "/h/server-1/agent", + pathname: "/h/server-1/new", toggleFileExplorer, }); @@ -27,7 +45,7 @@ describe("keyboard-shortcut-routing", () => { it("blocks the shortcut when no toggle handler exists", () => { const canToggle = canToggleFileExplorerShortcut({ - pathname: "/h/server-1/agent", + pathname: "/h/server-1/new", }); expect(canToggle).toBe(false); diff --git a/packages/app/src/keyboard/keyboard-shortcut-routing.ts b/packages/app/src/keyboard/keyboard-shortcut-routing.ts index 4bda73560..977e87e65 100644 --- a/packages/app/src/keyboard/keyboard-shortcut-routing.ts +++ b/packages/app/src/keyboard/keyboard-shortcut-routing.ts @@ -1,6 +1,8 @@ import { parseHostAgentDraftRouteFromPathname, parseHostAgentRouteFromPathname, + parseHostWorkspaceAgentRouteFromPathname, + parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; const DRAFT_AGENT_ID = "__new_agent__"; @@ -12,7 +14,9 @@ export function resolveSelectedOrRouteAgentKey(input: { if (input.selectedAgentId) { return input.selectedAgentId; } - const route = parseHostAgentRouteFromPathname(input.pathname); + const route = + parseHostWorkspaceAgentRouteFromPathname(input.pathname) ?? + parseHostAgentRouteFromPathname(input.pathname); if (!route) { const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname); if (!draftRoute) { @@ -31,10 +35,17 @@ export function canToggleFileExplorerShortcut(input: { if (!input.toggleFileExplorer) { return false; } - return ( - resolveSelectedOrRouteAgentKey({ - selectedAgentId: input.selectedAgentId, - pathname: input.pathname, - }) !== null - ); + if (parseHostWorkspaceRouteFromPathname(input.pathname)) { + return true; + } + + if (parseHostAgentRouteFromPathname(input.pathname)) { + return true; + } + + if (parseHostAgentDraftRouteFromPathname(input.pathname)) { + return true; + } + + return false; } diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 886ef8a49..f1a22430e 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -105,9 +105,15 @@ function logAgentExplorer(event: string, details: Record): void export function AgentReadyScreen({ serverId, agentId, + showHeader = true, + showExplorerSidebar = true, + wrapWithExplorerSidebarProvider = true, }: { serverId: string; agentId: string; + showHeader?: boolean; + showExplorerSidebar?: boolean; + wrapWithExplorerSidebarProvider?: boolean; }) { const router = useRouter(); const resolvedAgentId = agentId?.trim() || undefined; @@ -193,17 +199,23 @@ export function AgentReadyScreen({ ); } - return ( - - - + const content = ( + ); + + if (!wrapWithExplorerSidebarProvider) { + return content; + } + + return {content}; } type AgentScreenContentProps = { @@ -212,6 +224,8 @@ type AgentScreenContentProps = { client: DaemonClient; isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; + showHeader: boolean; + showExplorerSidebar: boolean; }; function toErrorMessage(error: unknown): string { @@ -231,6 +245,8 @@ function AgentScreenContent({ client, isConnected, connectionStatus, + showHeader, + showExplorerSidebar, }: AgentScreenContentProps) { const { theme } = useUnistyles(); const toast = useToast(); @@ -990,7 +1006,7 @@ function AgentScreenContent({ if (viewState.tag === "not_found") { return ( - + {showHeader ? : null} Agent not found @@ -1001,7 +1017,7 @@ function AgentScreenContent({ if (viewState.tag === "error") { return ( - + {showHeader ? : null} Failed to load agent {viewState.message} @@ -1013,7 +1029,7 @@ function AgentScreenContent({ if (viewState.tag === "boot" || !effectiveAgent) { return ( - + {showHeader ? : null} @@ -1029,80 +1045,81 @@ function AgentScreenContent({ > {/* Header */} - - - {isMobile ? ( - checkout?.isGit ? ( - + {showHeader ? ( + + + {isMobile ? ( + checkout?.isGit ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - + { + if (open && agent?.cwd) { + checkoutStatusQuery.refresh().catch(() => {}); } - /> - )} - - { - if (open && agent?.cwd) { - checkoutStatusQuery.refresh().catch(() => {}); - } - }} - > - - - - - - [ - styles.menuMetaRow, - (hovered || pressed) && styles.menuMetaRowActive, - ]} - onPress={() => { - void handleCopyMeta("Directory", effectiveAgent.cwd); - }} - > - - Directory - + }} + > + + + + + + [ + styles.menuMetaRow, + (hovered || pressed) && styles.menuMetaRowActive, + ]} + onPress={() => { + void handleCopyMeta("Directory", effectiveAgent.cwd); + }} + > + + Directory + {isInitializing ? "Refreshing..." : "Refresh"} - - - - } - /> + + + + } + /> + ) : null} {/* Content Area with Keyboard Animation */} @@ -1272,14 +1290,14 @@ function AgentScreenContent({ {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} - {!isMobile && isExplorerOpen && resolvedAgentId && ( + {showExplorerSidebar && !isMobile && isExplorerOpen && resolvedAgentId ? ( - )} + ) : null} {isArchivingCurrentAgent ? ( @@ -1304,14 +1322,14 @@ function AgentScreenContent({ )} {/* Mobile Explorer Sidebar Overlay */} - {isMobile && resolvedAgentId && ( + {showExplorerSidebar && isMobile && resolvedAgentId ? ( - )} + ) : null} ); } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index faf086940..04651c3da 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -1342,8 +1342,8 @@ function DraftAgentScreenContent({ {!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? ( ) : null} @@ -1365,8 +1365,8 @@ function DraftAgentScreenContent({ {isMobile && explorerServerId && draftExplorerCheckout ? ( ) : null} diff --git a/packages/app/src/screens/agent/legacy-agent-id-screen.tsx b/packages/app/src/screens/agent/legacy-agent-id-screen.tsx deleted file mode 100644 index c18cae9ef..000000000 --- a/packages/app/src/screens/agent/legacy-agent-id-screen.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { useCallback, useEffect, useMemo } from "react"; -import type { ReactNode } from "react"; -import { ActivityIndicator, Pressable, Text, View } from "react-native"; -import { useRouter } from "expo-router"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { BackHeader } from "@/components/headers/back-header"; -import { useSessionDirectory } from "@/hooks/use-session-directory"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import type { Agent } from "@/contexts/session-context"; -import { - buildHostAgentDetailRoute, - buildHostAgentDraftRoute, -} from "@/utils/host-routes"; - -type AgentMatch = { - serverId: string; - serverLabel: string; - agent: Agent; -}; - -export function LegacyAgentIdScreen({ agentId }: { agentId: string }) { - const router = useRouter(); - const { theme } = useUnistyles(); - const { daemons } = useDaemonRegistry(); - const sessionDirectory = useSessionDirectory(); - const resolvedAgentId = typeof agentId === "string" ? agentId.trim() : undefined; - - const matches = useMemo(() => { - if (!resolvedAgentId) { - return []; - } - - const results: AgentMatch[] = []; - sessionDirectory.forEach((session, serverId) => { - if (!session) { - return; - } - const agent = session.agents.get(resolvedAgentId); - if (!agent) { - return; - } - const serverLabel = - daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; - results.push({ serverId, serverLabel, agent }); - }); - - return results; - }, [daemons, resolvedAgentId, sessionDirectory]); - - const hasSessions = sessionDirectory.size > 0; - const isRedirecting = Boolean(resolvedAgentId && matches.length === 1); - - useEffect(() => { - if (!isRedirecting) { - return; - } - const match = matches[0]; - router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any); - }, [isRedirecting, matches, router]); - - const handleGoDraft = useCallback(() => { - const firstMatchServerId = matches[0]?.serverId ?? null; - if (firstMatchServerId) { - router.replace(buildHostAgentDraftRoute(firstMatchServerId) as any); - return; - } - router.replace("/" as any); - }, [matches, router]); - - const handleSelectMatch = useCallback( - (match: AgentMatch) => { - router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any); - }, - [router] - ); - - let body: ReactNode = null; - - if (!resolvedAgentId) { - body = ( - - Missing agent - - This link is missing an agent id. Go back to the Agents screen to pick one. - - - New agent - - - ); - } else if (!hasSessions || isRedirecting) { - body = ( - - - - {isRedirecting ? "Opening your agent..." : "Looking for your agent..."} - - - ); - } else if (matches.length === 0) { - body = ( - - Agent not found - - We could not find {resolvedAgentId} on any host right now. Hosts reconnect automatically—open it - again from the Agents screen after it comes back online. - - - New agent - - - ); - } else if (matches.length > 1) { - body = ( - - Pick a host - - Multiple hosts have an agent with this id. Choose the one you intended to open. - - - {matches.map((match) => ( - handleSelectMatch(match)} - > - {match.serverLabel} - - {match.agent.cwd} - - - ))} - - - ); - } - - return ( - - - {body} - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - content: { - flex: 1, - paddingHorizontal: theme.spacing[6], - paddingVertical: theme.spacing[8], - }, - centerState: { - flex: 1, - justifyContent: "center", - alignItems: "center", - gap: theme.spacing[4], - }, - title: { - fontSize: theme.fontSize.xl, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - }, - subtitle: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - primaryButton: { - marginTop: theme.spacing[2], - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.primary, - }, - primaryButtonText: { - color: theme.colors.primaryForeground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - matchList: { - width: "100%", - gap: theme.spacing[3], - }, - matchButton: { - width: "100%", - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface2, - }, - matchLabel: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - }, - matchMeta: { - marginTop: theme.spacing[1], - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, -})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx new file mode 100644 index 000000000..3c8cbc131 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -0,0 +1,1013 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + BackHandler, + Platform, + Pressable, + ScrollView, + Text, + View, +} from "react-native"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import { ChevronDown, Plus, Terminal } from "lucide-react-native"; +import { GestureDetector } from "react-native-gesture-handler"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { SidebarMenuToggle } from "@/components/headers/menu-header"; +import { ScreenHeader } from "@/components/headers/screen-header"; +import { Combobox } from "@/components/ui/combobox"; +import { AgentStatusDot } from "@/components/agent-status-dot"; +import { ExplorerSidebar } from "@/components/explorer-sidebar"; +import { TerminalPane } from "@/components/terminal-pane"; +import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; +import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; +import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { + buildWorkspaceTabPersistenceKey, + useWorkspaceTabsStore, + type WorkspaceTabTarget, +} from "@/stores/workspace-tabs-store"; +import { + buildHostWorkspaceAgentRoute, + buildHostWorkspaceTerminalRoute, +} from "@/utils/host-routes"; +import { buildNewAgentRoute } from "@/utils/new-agent-routing"; +import { useHostRuntimeSession } from "@/runtime/host-runtime"; +import { + checkoutStatusQueryKey, + type CheckoutStatusPayload, +} from "@/hooks/use-checkout-status-query"; +import { deriveBranchLabel } from "@/utils/agent-display-info"; +import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; +import type { ListTerminalsResponse } from "@server/shared/messages"; +import { upsertTerminalListEntry } from "@/utils/terminal-list"; + +const TERMINALS_QUERY_STALE_TIME = 5_000; +const CREATE_TERMINAL_SWITCHER_ID = "__create_terminal__"; + +type RouteTabTarget = WorkspaceTabTarget | null; + +type WorkspaceScreenProps = { + serverId: string; + workspaceId: string; + routeTab: RouteTabTarget; +}; + +type WorkspaceTabDescriptor = + | { + key: string; + kind: "agent"; + agentId: string; + label: string; + subtitle: string; + } + | { + key: string; + kind: "terminal"; + terminalId: string; + label: string; + subtitle: string; + }; + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function decodeSegment(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function deriveWorkspaceName(workspaceId: string): string { + const normalized = workspaceId.replace(/\\/g, "/"); + const segments = normalized.split("/").filter(Boolean); + const last = segments[segments.length - 1]; + return last ?? workspaceId; +} + +function normalizeWorkspaceTab( + value: WorkspaceTabTarget | null | undefined +): WorkspaceTabTarget | null { + if (!value || typeof value !== "object") { + return null; + } + if (value.kind === "agent") { + const agentId = trimNonEmpty(decodeSegment(value.agentId)); + if (!agentId) { + return null; + } + return { kind: "agent", agentId }; + } + if (value.kind === "terminal") { + const terminalId = trimNonEmpty(decodeSegment(value.terminalId)); + if (!terminalId) { + return null; + } + return { kind: "terminal", terminalId }; + } + return null; +} + +function tabEquals(left: WorkspaceTabTarget | null, right: WorkspaceTabTarget | null): boolean { + if (!left || !right) { + return left === right; + } + if (left.kind !== right.kind) { + return false; + } + if (left.kind === "agent" && right.kind === "agent") { + return left.agentId === right.agentId; + } + if (left.kind === "terminal" && right.kind === "terminal") { + return left.terminalId === right.terminalId; + } + return false; +} + +function buildTabRoute(input: { + serverId: string; + workspaceId: string; + tab: WorkspaceTabTarget; +}): string { + if (input.tab.kind === "agent") { + return buildHostWorkspaceAgentRoute( + input.serverId, + input.workspaceId, + input.tab.agentId + ); + } + return buildHostWorkspaceTerminalRoute( + input.serverId, + input.workspaceId, + input.tab.terminalId + ); +} + +function isTabAvailable(input: { + tab: WorkspaceTabTarget; + agentsById: Map; + terminalIds: Set; +}): boolean { + if (input.tab.kind === "agent") { + return input.agentsById.has(input.tab.agentId); + } + return input.terminalIds.has(input.tab.terminalId); +} + +function sortAgentsByCreatedAtDescending(agents: Agent[]): Agent[] { + return [...agents].sort((left, right) => { + const createdAtDelta = + right.createdAt.getTime() - left.createdAt.getTime(); + if (createdAtDelta !== 0) { + return createdAtDelta; + } + return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); + }); +} + +export function WorkspaceScreen({ + serverId, + workspaceId, + routeTab, +}: WorkspaceScreenProps) { + return ( + + + + ); +} + +function WorkspaceScreenContent({ + serverId, + workspaceId, + routeTab, +}: WorkspaceScreenProps) { + const { theme } = useUnistyles(); + const router = useRouter(); + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + + const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; + const normalizedWorkspaceId = trimNonEmpty(decodeSegment(workspaceId)) ?? ""; + + const queryClient = useQueryClient(); + const { client, isConnected } = useHostRuntimeSession(normalizedServerId); + + const sessionAgents = useSessionStore( + (state) => state.sessions[normalizedServerId]?.agents + ); + const workspaceAgents = useMemo(() => { + if (!sessionAgents || !normalizedWorkspaceId) { + return [] as Agent[]; + } + + const collected: Agent[] = []; + for (const agent of sessionAgents.values()) { + if (agent.archivedAt) { + continue; + } + if ((trimNonEmpty(agent.cwd) ?? "") !== normalizedWorkspaceId) { + continue; + } + collected.push(agent); + } + + return sortAgentsByCreatedAtDescending(collected); + }, [normalizedWorkspaceId, sessionAgents]); + + const terminalsQueryKey = useMemo( + () => ["terminals", normalizedServerId, normalizedWorkspaceId] as const, + [normalizedServerId, normalizedWorkspaceId] + ); + type ListTerminalsPayload = ListTerminalsResponse["payload"]; + const terminalsQuery = useQuery({ + queryKey: terminalsQueryKey, + enabled: + Boolean(client && isConnected) && + normalizedWorkspaceId.length > 0 && + normalizedWorkspaceId.startsWith("/"), + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return await client.listTerminals(normalizedWorkspaceId); + }, + staleTime: TERMINALS_QUERY_STALE_TIME, + }); + const terminals = terminalsQuery.data?.terminals ?? []; + const createTerminalMutation = useMutation({ + mutationFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return await client.createTerminal(normalizedWorkspaceId); + }, + onSuccess: (payload) => { + const createdTerminal = payload.terminal; + if (createdTerminal) { + queryClient.setQueryData( + terminalsQueryKey, + (current) => { + const nextTerminals = upsertTerminalListEntry({ + terminals: current?.terminals ?? [], + terminal: createdTerminal, + }); + return { + cwd: current?.cwd ?? normalizedWorkspaceId, + terminals: nextTerminals, + requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`, + }; + } + ); + } + + void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); + if (createdTerminal) { + navigateToTab({ kind: "terminal", terminalId: createdTerminal.id }); + } + }, + }); + + useEffect(() => { + if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) { + return; + } + + const unsubscribeChanged = client.on("terminals_changed", (message) => { + if (message.type !== "terminals_changed") { + return; + } + if (message.payload.cwd !== normalizedWorkspaceId) { + return; + } + void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); + void queryClient.refetchQueries({ queryKey: terminalsQueryKey, type: "active" }); + }); + + const unsubscribeStreamExit = client.on("terminal_stream_exit", (message) => { + if (message.type !== "terminal_stream_exit") { + return; + } + void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); + void queryClient.refetchQueries({ queryKey: terminalsQueryKey, type: "active" }); + }); + + client.subscribeTerminals({ cwd: normalizedWorkspaceId }); + + return () => { + unsubscribeChanged(); + unsubscribeStreamExit(); + client.unsubscribeTerminals({ cwd: normalizedWorkspaceId }); + }; + }, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]); + + const checkoutQuery = useQuery({ + queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId), + enabled: + Boolean(client && isConnected) && + normalizedWorkspaceId.length > 0 && + normalizedWorkspaceId.startsWith("/"), + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return (await client.getCheckoutStatus( + normalizedWorkspaceId + )) as CheckoutStatusPayload; + }, + staleTime: 15_000, + }); + + const workspaceName = useMemo( + () => deriveWorkspaceName(normalizedWorkspaceId), + [normalizedWorkspaceId] + ); + const branchLabel = useMemo( + () => deriveBranchLabel(checkoutQuery.data ?? null), + [checkoutQuery.data] + ); + const headerTitle = branchLabel ?? workspaceName; + + const isGitCheckout = checkoutQuery.data?.isGit ?? false; + + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore( + (state) => state.desktop.fileExplorerOpen + ); + const openFileExplorer = usePanelStore((state) => state.openFileExplorer); + const closeToAgent = usePanelStore((state) => state.closeToAgent); + const setActiveExplorerCheckout = usePanelStore( + (state) => state.setActiveExplorerCheckout + ); + + const isExplorerOpen = isMobile + ? mobileView === "file-explorer" + : desktopFileExplorerOpen; + + const activeExplorerCheckout = useMemo(() => { + if (!normalizedServerId || !normalizedWorkspaceId.startsWith("/")) { + return null; + } + return { + serverId: normalizedServerId, + cwd: normalizedWorkspaceId, + isGit: isGitCheckout, + }; + }, [isGitCheckout, normalizedServerId, normalizedWorkspaceId]); + + useEffect(() => { + setActiveExplorerCheckout(activeExplorerCheckout); + }, [activeExplorerCheckout, setActiveExplorerCheckout]); + + const openExplorerForWorkspace = useCallback(() => { + if (!activeExplorerCheckout) { + return; + } + openFileExplorer(); + }, [activeExplorerCheckout, openFileExplorer]); + + const explorerOpenGesture = useExplorerOpenGesture({ + enabled: isMobile && mobileView === "agent", + onOpen: openExplorerForWorkspace, + }); + + useEffect(() => { + if (Platform.OS === "web" || !isExplorerOpen) { + return; + } + + const handler = BackHandler.addEventListener("hardwareBackPress", () => { + if (isExplorerOpen) { + closeToAgent(); + return true; + } + return false; + }); + + return () => handler.remove(); + }, [closeToAgent, isExplorerOpen]); + + const agentsById = useMemo(() => { + const map = new Map(); + for (const agent of workspaceAgents) { + map.set(agent.id, agent); + } + return map; + }, [workspaceAgents]); + + const tabs = useMemo(() => { + const next: WorkspaceTabDescriptor[] = []; + + for (const agent of workspaceAgents) { + next.push({ + key: `agent:${agent.id}`, + kind: "agent", + agentId: agent.id, + label: agent.title?.trim() || "New agent", + subtitle: "Agent", + }); + } + + for (const terminal of terminals) { + next.push({ + key: `terminal:${terminal.id}`, + kind: "terminal", + terminalId: terminal.id, + label: terminal.name, + subtitle: "Terminal", + }); + } + + return next; + }, [terminals, workspaceAgents]); + + const terminalIds = useMemo(() => { + const set = new Set(); + for (const terminal of terminals) { + set.add(terminal.id); + } + return set; + }, [terminals]); + + const requestedTab = useMemo( + () => normalizeWorkspaceTab(routeTab), + [routeTab] + ); + + const persistenceKey = useMemo( + () => + buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + }), + [normalizedServerId, normalizedWorkspaceId] + ); + const lastFocusedTabByWorkspace = useWorkspaceTabsStore( + (state) => state.lastFocusedTabByWorkspace + ); + const setLastFocusedTab = useWorkspaceTabsStore( + (state) => state.setLastFocusedTab + ); + + const storedTab = useMemo(() => { + if (!persistenceKey) { + return null; + } + return normalizeWorkspaceTab(lastFocusedTabByWorkspace[persistenceKey]); + }, [lastFocusedTabByWorkspace, persistenceKey]); + + const fallbackTab = useMemo(() => { + const first = tabs[0]; + if (!first) { + return null; + } + if (first.kind === "agent") { + return { kind: "agent", agentId: first.agentId }; + } + return { kind: "terminal", terminalId: first.terminalId }; + }, [tabs]); + + const resolvedTab = useMemo(() => { + if ( + requestedTab && + isTabAvailable({ + tab: requestedTab, + agentsById, + terminalIds, + }) + ) { + return requestedTab; + } + + if ( + storedTab && + isTabAvailable({ + tab: storedTab, + agentsById, + terminalIds, + }) + ) { + return storedTab; + } + + return fallbackTab; + }, [agentsById, fallbackTab, requestedTab, storedTab, terminalIds]); + + const navigateToTab = useCallback( + (tab: WorkspaceTabTarget) => { + if (tabEquals(tab, resolvedTab)) { + return; + } + const targetRoute = buildTabRoute({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tab, + }); + setLastFocusedTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tab, + }); + router.replace(targetRoute as any); + }, + [ + normalizedServerId, + normalizedWorkspaceId, + resolvedTab, + router, + setLastFocusedTab, + ] + ); + + useEffect(() => { + if (!resolvedTab) { + return; + } + + setLastFocusedTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tab: resolvedTab, + }); + }, [ + normalizedServerId, + normalizedWorkspaceId, + resolvedTab, + setLastFocusedTab, + ]); + + useEffect(() => { + if (!resolvedTab) { + return; + } + + if (tabEquals(requestedTab, resolvedTab)) { + return; + } + + const targetRoute = buildTabRoute({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tab: resolvedTab, + }); + + router.replace(targetRoute as any); + }, [ + normalizedServerId, + normalizedWorkspaceId, + requestedTab, + resolvedTab, + router, + ]); + + const [isTabSwitcherOpen, setIsTabSwitcherOpen] = useState(false); + const tabSwitcherAnchorRef = useRef(null); + + const tabByKey = useMemo(() => { + const map = new Map(); + for (const tab of tabs) { + if (tab.kind === "agent") { + map.set(tab.key, { kind: "agent", agentId: tab.agentId }); + continue; + } + map.set(tab.key, { kind: "terminal", terminalId: tab.terminalId }); + } + return map; + }, [tabs]); + + const activeTabKey = useMemo(() => { + if (!resolvedTab) { + return ""; + } + if (resolvedTab.kind === "agent") { + return `agent:${resolvedTab.agentId}`; + } + return `terminal:${resolvedTab.terminalId}`; + }, [resolvedTab]); + + const tabSwitcherOptions = useMemo( + () => + [ + ...tabs.map((tab) => ({ + id: tab.key, + label: tab.label, + description: tab.subtitle, + })), + { + id: CREATE_TERMINAL_SWITCHER_ID, + label: "New terminal", + description: "Create", + }, + ], + [tabs] + ); + + const activeTabLabel = useMemo(() => { + const active = tabs.find((tab) => tab.key === activeTabKey); + return active?.label ?? "Select tab"; + }, [activeTabKey, tabs]); + + const handleCreateAgent = useCallback(() => { + if (!normalizedServerId) { + return; + } + router.push( + buildNewAgentRoute(normalizedServerId, normalizedWorkspaceId) as any + ); + }, [normalizedServerId, normalizedWorkspaceId, router]); + + const handleCreateTerminal = useCallback(() => { + if (!normalizedWorkspaceId.startsWith("/")) { + return; + } + createTerminalMutation.mutate(); + }, [createTerminalMutation, normalizedWorkspaceId]); + + const handleSelectSwitcherTab = useCallback( + (key: string) => { + if (key === CREATE_TERMINAL_SWITCHER_ID) { + setIsTabSwitcherOpen(false); + handleCreateTerminal(); + return; + } + const tab = tabByKey.get(key); + if (!tab) { + return; + } + setIsTabSwitcherOpen(false); + navigateToTab(tab); + }, + [handleCreateTerminal, navigateToTab, tabByKey] + ); + + const renderContent = () => { + if (!resolvedTab) { + return ( + + + No agent or terminal tabs are available for this workspace. + + [ + styles.emptyStateButton, + (hovered || pressed) && styles.emptyStateButtonActive, + ]} + onPress={handleCreateAgent} + > + Create agent + + [ + styles.emptyStateButton, + (hovered || pressed) && styles.emptyStateButtonActive, + ]} + onPress={handleCreateTerminal} + disabled={createTerminalMutation.isPending} + > + {createTerminalMutation.isPending ? ( + + ) : ( + Create terminal + )} + + + ); + } + + if (resolvedTab.kind === "agent") { + return ( + + ); + } + + return ( + { + if (!terminalId) { + return; + } + navigateToTab({ kind: "terminal", terminalId }); + }} + hideHeader + manageTerminalDirectorySubscription={false} + /> + ); + }; + + return ( + + + + + {headerTitle} + + + } + right={ + + {isMobile ? ( + [ + styles.switcherTrigger, + (hovered || pressed) && styles.switcherTriggerActive, + ]} + onPress={() => setIsTabSwitcherOpen(true)} + > + + {activeTabLabel} + + + + ) : null} + + [ + styles.plusButton, + (hovered || pressed) && styles.plusButtonActive, + ]} + onPress={handleCreateAgent} + accessibilityRole="button" + accessibilityLabel="Create agent" + > + + + + {isMobile ? ( + + ) : null} + + } + /> + + {!isMobile ? ( + + + {tabs.map((tab) => { + const isActive = tab.key === activeTabKey; + const icon = + tab.kind === "agent" ? ( + + ) : ( + + ); + + return ( + [ + styles.tab, + isActive && styles.tabActive, + (hovered || pressed) && styles.tabHovered, + ]} + onPress={() => { + if (tab.kind === "agent") { + navigateToTab({ kind: "agent", agentId: tab.agentId }); + return; + } + navigateToTab({ + kind: "terminal", + terminalId: tab.terminalId, + }); + }} + > + {icon} + + {tab.label} + + + ); + })} + + [ + styles.createTerminalButton, + (hovered || pressed) && styles.createTerminalButtonActive, + ]} + > + {createTerminalMutation.isPending ? ( + + ) : ( + + + + + + + )} + + + ) : null} + + + {isMobile ? ( + + {renderContent()} + + ) : ( + {renderContent()} + )} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + headerTitle: { + flex: 1, + fontSize: theme.fontSize.base, + fontWeight: { + xs: "400", + md: "300", + }, + color: theme.colors.foreground, + }, + headerRight: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + plusButton: { + padding: { + xs: theme.spacing[3], + md: theme.spacing[2], + }, + borderRadius: theme.borderRadius.lg, + }, + plusButtonActive: { + backgroundColor: theme.colors.surface2, + }, + switcherTrigger: { + maxWidth: 220, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + }, + switcherTriggerActive: { + backgroundColor: theme.colors.surface2, + }, + switcherTriggerText: { + minWidth: 0, + flexShrink: 1, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + tabsContainer: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + backgroundColor: theme.colors.surface0, + flexDirection: "row", + alignItems: "center", + }, + tabsScroll: { + flex: 1, + }, + tabsContent: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + }, + mainRow: { + flex: 1, + minHeight: 0, + flexDirection: "row", + }, + tab: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + maxWidth: 260, + }, + tabIcon: { + flexShrink: 0, + }, + tabActive: { + backgroundColor: theme.colors.surface2, + }, + tabHovered: { + backgroundColor: theme.colors.surface2, + }, + tabLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, + tabLabelActive: { + color: theme.colors.foreground, + }, + createTerminalButton: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + marginRight: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, + createTerminalButtonActive: { + backgroundColor: theme.colors.surface2, + }, + createTerminalIcon: { + position: "relative", + justifyContent: "center", + alignItems: "center", + }, + createTerminalPlusIcon: { + position: "absolute", + top: -6, + right: -6, + }, + content: { + flex: 1, + minHeight: 0, + }, + emptyState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + }, + emptyStateText: { + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + emptyStateButton: { + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[2], + }, + emptyStateButtonActive: { + backgroundColor: theme.colors.surface2, + }, + emptyStateButtonText: { + color: theme.colors.foreground, + fontWeight: theme.fontWeight.medium, + }, +})); diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index ec884ee9b..1c9d5d512 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -18,7 +18,7 @@ interface DownloadProgress { export interface Download { id: string; serverId: string; - agentId: string; + scopeId: string; fileName: string; status: "downloading" | "complete" | "error"; message?: string; @@ -32,14 +32,11 @@ interface DownloadState { startDownload: (params: { serverId: string; - agentId: string; + scopeId: string; fileName: string; path: string; daemonProfile: HostProfile | undefined; - requestFileDownloadToken: ( - agentId: string, - path: string - ) => Promise<{ + requestFileDownloadToken: (path: string) => Promise<{ token: string | null; fileName: string | null; mimeType: string | null; @@ -64,7 +61,7 @@ export const useDownloadStore = create()((set, get) => ({ startDownload: async ({ serverId, - agentId, + scopeId, fileName, path, daemonProfile, @@ -74,7 +71,7 @@ export const useDownloadStore = create()((set, get) => ({ const download: Download = { id, serverId, - agentId, + scopeId, fileName, status: "downloading", startedAt: Date.now(), @@ -86,7 +83,7 @@ export const useDownloadStore = create()((set, get) => ({ })); try { - const tokenResponse = await requestFileDownloadToken(agentId, path); + const tokenResponse = await requestFileDownloadToken(path); if (tokenResponse.error || !tokenResponse.token) { throw new Error(tokenResponse.error ?? "Failed to request download token."); } diff --git a/packages/app/src/stores/explorer-tab-memory.ts b/packages/app/src/stores/explorer-tab-memory.ts index 6bc9463d1..7a5e92225 100644 --- a/packages/app/src/stores/explorer-tab-memory.ts +++ b/packages/app/src/stores/explorer-tab-memory.ts @@ -1,7 +1,7 @@ -export type ExplorerTab = "changes" | "files" | "terminals"; +export type ExplorerTab = "changes" | "files"; export function isExplorerTab(value: unknown): value is ExplorerTab { - return value === "changes" || value === "files" || value === "terminals"; + return value === "changes" || value === "files"; } export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null { diff --git a/packages/app/src/stores/panel-store.test.ts b/packages/app/src/stores/panel-store.test.ts index e128b5403..bc35e5074 100644 --- a/packages/app/src/stores/panel-store.test.ts +++ b/packages/app/src/stores/panel-store.test.ts @@ -44,7 +44,7 @@ describe("panel-store explorer tab resolution", () => { ).toBe("files"); }); - it("restores a stored terminals tab for git checkouts", () => { + it("falls back to default when stored tab is invalid", () => { const key = buildExplorerCheckoutKey(serverId, cwd)!; expect( resolveExplorerTabForCheckout({ @@ -52,10 +52,10 @@ describe("panel-store explorer tab resolution", () => { cwd, isGit: true, explorerTabByCheckout: { - [key]: "terminals", + [key]: "terminals" as any, }, }) - ).toBe("terminals"); + ).toBe("changes"); }); it("coerces stored changes to files for non-git checkouts", () => { diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index 592271099..58011fffb 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -336,7 +336,7 @@ export const usePanelStore = create()( }), { name: "panel-state", - version: 4, + version: 5, storage: createJSONStorage(() => AsyncStorage), migrate: (persistedState, version) => { const state = persistedState as Partial & Record; @@ -369,6 +369,10 @@ export const usePanelStore = create()( } } + if (!isExplorerTab(state.explorerTab)) { + state.explorerTab = "changes"; + } + if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) { state.explorerTabByCheckout = {}; } else { diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index eaf08c56f..00063652f 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -1,99 +1,93 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import AsyncStorage from '@react-native-async-storage/async-storage' +import { create } from 'zustand' +import { createJSONStorage, persist } from 'zustand/middleware' interface SidebarOrderStoreState { - orderByServerId: Record; - getOrder: (serverId: string) => string[]; - setOrder: (serverId: string, keys: string[]) => void; - insertAtTop: (serverId: string, key: string) => void; - remove: (serverId: string, key: string) => void; + projectOrderByServerId: Record + workspaceOrderByServerAndProject: Record + getProjectOrder: (serverId: string) => string[] + setProjectOrder: (serverId: string, keys: string[]) => void + getWorkspaceOrder: (serverId: string, projectKey: string) => string[] + setWorkspaceOrder: (serverId: string, projectKey: string, keys: string[]) => void } function normalizeKeys(keys: string[]): string[] { - const seen = new Set(); - const normalized: string[] = []; + const seen = new Set() + const normalized: string[] = [] for (const rawKey of keys) { - const key = rawKey.trim(); + const key = rawKey.trim() if (!key || seen.has(key)) { - continue; + continue } - seen.add(key); - normalized.push(key); + seen.add(key) + normalized.push(key) } - return normalized; + return normalized +} + +function buildWorkspaceScopeKey(serverId: string, projectKey: string): string { + return `${serverId.trim()}::${projectKey.trim()}` } export const useSidebarOrderStore = create()( persist( (set, get) => ({ - orderByServerId: {}, - getOrder: (serverId) => { - const key = serverId.trim(); + projectOrderByServerId: {}, + workspaceOrderByServerAndProject: {}, + getProjectOrder: (serverId) => { + const key = serverId.trim() if (!key) { - return []; + return [] } - return get().orderByServerId[key] ?? []; + return get().projectOrderByServerId[key] ?? [] }, - setOrder: (serverId, keys) => { - const key = serverId.trim(); + setProjectOrder: (serverId, keys) => { + const key = serverId.trim() if (!key) { - return; + return } - const normalized = normalizeKeys(keys); + const normalized = normalizeKeys(keys) set((state) => ({ - orderByServerId: { - ...state.orderByServerId, + projectOrderByServerId: { + ...state.projectOrderByServerId, [key]: normalized, }, - })); + })) }, - insertAtTop: (serverId, rawKey) => { - const serverKey = serverId.trim(); - const entryKey = rawKey.trim(); - if (!serverKey || !entryKey) { - return; + getWorkspaceOrder: (serverId, projectKey) => { + const serverKey = serverId.trim() + const projectScope = projectKey.trim() + if (!serverKey || !projectScope) { + return [] } - set((state) => { - const current = state.orderByServerId[serverKey] ?? []; - const next = [entryKey, ...current.filter((key) => key !== entryKey)]; - return { - orderByServerId: { - ...state.orderByServerId, - [serverKey]: next, - }, - }; - }); + const scopeKey = buildWorkspaceScopeKey(serverKey, projectScope) + return get().workspaceOrderByServerAndProject[scopeKey] ?? [] }, - remove: (serverId, rawKey) => { - const serverKey = serverId.trim(); - const entryKey = rawKey.trim(); - if (!serverKey || !entryKey) { - return; + setWorkspaceOrder: (serverId, projectKey, keys) => { + const serverKey = serverId.trim() + const projectScope = projectKey.trim() + if (!serverKey || !projectScope) { + return } - set((state) => { - const current = state.orderByServerId[serverKey] ?? []; - const next = current.filter((key) => key !== entryKey); - if (next.length === current.length) { - return state; - } - return { - orderByServerId: { - ...state.orderByServerId, - [serverKey]: next, - }, - }; - }); + const scopeKey = buildWorkspaceScopeKey(serverKey, projectScope) + const normalized = normalizeKeys(keys) + set((state) => ({ + workspaceOrderByServerAndProject: { + ...state.workspaceOrderByServerAndProject, + [scopeKey]: normalized, + }, + })) }, }), { - name: "sidebar-agent-order", + name: 'sidebar-project-workspace-order', storage: createJSONStorage(() => AsyncStorage), partialize: (state) => ({ - orderByServerId: state.orderByServerId, + projectOrderByServerId: state.projectOrderByServerId, + workspaceOrderByServerAndProject: state.workspaceOrderByServerAndProject, }), } ) -); +) diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts new file mode 100644 index 000000000..f9c9e4f88 --- /dev/null +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -0,0 +1,136 @@ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +export type WorkspaceTabTarget = + | { kind: "agent"; agentId: string } + | { kind: "terminal"; terminalId: string }; + +function trimNonEmpty(value: string | null | undefined): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeWorkspaceTab( + value: WorkspaceTabTarget | null | undefined +): WorkspaceTabTarget | null { + if (!value || typeof value !== "object" || typeof value.kind !== "string") { + return null; + } + if (value.kind === "agent") { + const agentId = trimNonEmpty(value.agentId); + if (!agentId) { + return null; + } + return { kind: "agent", agentId }; + } + if (value.kind === "terminal") { + const terminalId = trimNonEmpty(value.terminalId); + if (!terminalId) { + return null; + } + return { kind: "terminal", terminalId }; + } + return null; +} + +export function buildWorkspaceTabPersistenceKey(input: { + serverId: string; + workspaceId: string; +}): string | null { + const serverId = trimNonEmpty(input.serverId); + const workspaceId = trimNonEmpty(input.workspaceId); + if (!serverId || !workspaceId) { + return null; + } + return `${serverId}:${workspaceId}`; +} + +type WorkspaceTabsState = { + lastFocusedTabByWorkspace: Record; + setLastFocusedTab: (input: { + serverId: string; + workspaceId: string; + tab: WorkspaceTabTarget; + }) => void; + getLastFocusedTab: (input: { + serverId: string; + workspaceId: string; + }) => WorkspaceTabTarget | null; +}; + +export const useWorkspaceTabsStore = create()( + persist( + (set, get) => ({ + lastFocusedTabByWorkspace: {}, + setLastFocusedTab: ({ serverId, workspaceId, tab }) => { + const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + const normalizedTab = normalizeWorkspaceTab(tab); + if (!key || !normalizedTab) { + return; + } + + set((state) => { + const current = state.lastFocusedTabByWorkspace[key]; + if ( + current && + current.kind === normalizedTab.kind && + ((current.kind === "agent" && normalizedTab.kind === "agent" + ? current.agentId === normalizedTab.agentId + : current.kind === "terminal" && normalizedTab.kind === "terminal" + ? current.terminalId === normalizedTab.terminalId + : false)) + ) { + return state; + } + + return { + lastFocusedTabByWorkspace: { + ...state.lastFocusedTabByWorkspace, + [key]: normalizedTab, + }, + }; + }); + }, + getLastFocusedTab: ({ serverId, workspaceId }) => { + const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (!key) { + return null; + } + const value = get().lastFocusedTabByWorkspace[key]; + return normalizeWorkspaceTab(value); + }, + }), + { + name: "workspace-tabs-state", + version: 1, + storage: createJSONStorage(() => AsyncStorage), + migrate: (persistedState) => { + const state = persistedState as + | { + lastFocusedTabByWorkspace?: Record; + } + | undefined; + + const raw = state?.lastFocusedTabByWorkspace ?? {}; + const next: Record = {}; + + for (const key in raw) { + const value = raw[key]; + const normalized = normalizeWorkspaceTab(value); + if (normalized) { + next[key] = normalized; + } + } + + return { + ...state, + lastFocusedTabByWorkspace: next, + }; + }, + } + ) +); diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 93caea2e7..f0f4cb86e 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -2,18 +2,22 @@ import { describe, expect, it } from "vitest"; import { parseHostAgentDraftRouteFromPathname, parseHostAgentRouteFromPathname, + parseHostDraftRouteFromPathname, + parseHostWorkspaceAgentRouteFromPathname, + parseHostWorkspaceTerminalRouteFromPathname, + parseHostWorkspaceRouteFromPathname, } from "./host-routes"; describe("parseHostAgentDraftRouteFromPathname", () => { it("parses draft route server id", () => { - expect(parseHostAgentDraftRouteFromPathname("/h/local/agent")).toEqual({ + expect(parseHostAgentDraftRouteFromPathname("/h/local/new")).toEqual({ serverId: "local", }); }); it("parses encoded server id", () => { expect( - parseHostAgentDraftRouteFromPathname("/h/team%20host/agent") + parseHostAgentDraftRouteFromPathname("/h/team%20host/new") ).toEqual({ serverId: "team host", }); @@ -24,6 +28,14 @@ describe("parseHostAgentDraftRouteFromPathname", () => { }); }); +describe("parseHostDraftRouteFromPathname", () => { + it("parses /new draft routes", () => { + expect(parseHostDraftRouteFromPathname("/h/local/new")).toEqual({ + serverId: "local", + }); + }); +}); + describe("parseHostAgentRouteFromPathname", () => { it("continues parsing detail routes", () => { expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({ @@ -32,3 +44,38 @@ describe("parseHostAgentRouteFromPathname", () => { }); }); }); + +describe("workspace route parsing", () => { + it("parses workspace route", () => { + expect( + parseHostWorkspaceRouteFromPathname("/h/local/workspace/%2Ftmp%2Frepo") + ).toEqual({ + serverId: "local", + workspaceId: "/tmp/repo", + }); + }); + + it("parses workspace agent route", () => { + expect( + parseHostWorkspaceAgentRouteFromPathname( + "/h/local/workspace/%2Ftmp%2Frepo/agent/agent-1" + ) + ).toEqual({ + serverId: "local", + workspaceId: "/tmp/repo", + agentId: "agent-1", + }); + }); + + it("parses workspace terminal route", () => { + expect( + parseHostWorkspaceTerminalRouteFromPathname( + "/h/local/workspace/%2Ftmp%2Frepo/terminal/term-1" + ) + ).toEqual({ + serverId: "local", + workspaceId: "/tmp/repo", + terminalId: "term-1", + }); + }); +}); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index a1aba01bd..299de8a81 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -57,7 +57,7 @@ export function parseHostAgentRouteFromPathname( export function parseHostAgentDraftRouteFromPathname( pathname: string ): { serverId: string } | null { - const match = pathname.match(/^\/h\/([^/]+)\/agent\/?$/); + const match = pathname.match(/^\/h\/([^/]+)\/(?:agent|new)\/?$/); if (!match) { return null; } @@ -73,17 +73,151 @@ export function parseHostAgentDraftRouteFromPathname( } export function buildHostAgentDraftRoute(serverId: string): string { + return buildHostDraftRoute(serverId); +} + +export function parseHostDraftRouteFromPathname( + pathname: string +): { serverId: string } | null { + const match = pathname.match(/^\/h\/([^/]+)\/new\/?$/); + if (!match) { + return null; + } + const encodedServerId = match[1]; + if (!encodedServerId) { + return null; + } + const serverId = trimNonEmpty(decodeSegment(encodedServerId)); + if (!serverId) { + return null; + } + return { serverId }; +} + +export function buildHostDraftRoute(serverId: string): string { const normalized = trimNonEmpty(serverId); if (!normalized) { return "/"; } - return `/h/${encodeSegment(normalized)}/agent`; + return `/h/${encodeSegment(normalized)}/new`; +} + +export function parseHostWorkspaceRouteFromPathname( + pathname: string +): { serverId: string; workspaceId: string } | null { + const match = pathname.match(/^\/h\/([^/]+)\/workspace\/([^/]+)(?:\/|$)/); + if (!match) { + return null; + } + const [, encodedServerId, encodedWorkspaceId] = match; + if (!encodedServerId || !encodedWorkspaceId) { + return null; + } + const serverId = trimNonEmpty(decodeSegment(encodedServerId)); + const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId)); + if (!serverId || !workspaceId) { + return null; + } + return { serverId, workspaceId }; +} + +export function parseHostWorkspaceAgentRouteFromPathname( + pathname: string +): { serverId: string; workspaceId: string; agentId: string } | null { + const match = pathname.match( + /^\/h\/([^/]+)\/workspace\/([^/]+)\/agent\/([^/]+)(?:\/|$)/ + ); + if (!match) { + return null; + } + const [, encodedServerId, encodedWorkspaceId, encodedAgentId] = match; + if (!encodedServerId || !encodedWorkspaceId || !encodedAgentId) { + return null; + } + const serverId = trimNonEmpty(decodeSegment(encodedServerId)); + const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId)); + const agentId = trimNonEmpty(decodeSegment(encodedAgentId)); + if (!serverId || !workspaceId || !agentId) { + return null; + } + return { serverId, workspaceId, agentId }; +} + +export function parseHostWorkspaceTerminalRouteFromPathname( + pathname: string +): { serverId: string; workspaceId: string; terminalId: string } | null { + const match = pathname.match( + /^\/h\/([^/]+)\/workspace\/([^/]+)\/terminal\/([^/]+)(?:\/|$)/ + ); + if (!match) { + return null; + } + const [, encodedServerId, encodedWorkspaceId, encodedTerminalId] = match; + if (!encodedServerId || !encodedWorkspaceId || !encodedTerminalId) { + return null; + } + const serverId = trimNonEmpty(decodeSegment(encodedServerId)); + const workspaceId = trimNonEmpty(decodeSegment(encodedWorkspaceId)); + const terminalId = trimNonEmpty(decodeSegment(encodedTerminalId)); + if (!serverId || !workspaceId || !terminalId) { + return null; + } + return { serverId, workspaceId, terminalId }; +} + +export function buildHostWorkspaceRoute( + serverId: string, + workspaceId: string +): string { + const normalizedServerId = trimNonEmpty(serverId); + const normalizedWorkspaceId = trimNonEmpty(workspaceId); + if (!normalizedServerId || !normalizedWorkspaceId) { + return "/"; + } + return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment( + normalizedWorkspaceId + )}`; +} + +export function buildHostWorkspaceAgentRoute( + serverId: string, + workspaceId: string, + agentId: string +): string { + const base = buildHostWorkspaceRoute(serverId, workspaceId); + const normalizedAgentId = trimNonEmpty(agentId); + if (base === "/" || !normalizedAgentId) { + return "/"; + } + return `${base}/agent/${encodeSegment(normalizedAgentId)}`; +} + +export function buildHostWorkspaceTerminalRoute( + serverId: string, + workspaceId: string, + terminalId: string +): string { + const base = buildHostWorkspaceRoute(serverId, workspaceId); + const normalizedTerminalId = trimNonEmpty(terminalId); + if (base === "/" || !normalizedTerminalId) { + return "/"; + } + return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`; } export function buildHostAgentDetailRoute( serverId: string, - agentId: string + agentId: string, + workspaceId?: string ): string { + const normalizedWorkspaceId = trimNonEmpty(workspaceId); + if (normalizedWorkspaceId) { + return buildHostWorkspaceAgentRoute( + serverId, + normalizedWorkspaceId, + agentId + ); + } const normalizedServerId = trimNonEmpty(serverId); const normalizedAgentId = trimNonEmpty(agentId); if (!normalizedServerId || !normalizedAgentId) { @@ -127,5 +261,14 @@ export function mapPathnameToServer( if (suffix.startsWith("agents")) { return `${base}/agents`; } - return `${base}/agent`; + if (suffix.startsWith("new")) { + return `${base}/new`; + } + if (suffix.startsWith("workspace/")) { + return `${base}/${suffix}`; + } + if (suffix.startsWith("agent/")) { + return `${base}/${suffix}`; + } + return base; } diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts index 7bfae9520..bcc838beb 100644 --- a/packages/app/src/utils/new-agent-routing.test.ts +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -10,13 +10,13 @@ import { describe("buildNewAgentRoute", () => { it("falls back to host-scoped draft route when no working directory is provided", () => { - expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/agent"); - expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/agent"); + expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/new"); + expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/new"); }); it("encodes the working directory query parameter", () => { expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe( - "/h/srv-1/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo" + "/h/srv-1/new?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo" ); }); }); @@ -77,7 +77,7 @@ describe("resolveSelectedAgentForNewAgent", () => { it("prefers the agent in the current route", () => { expect( resolveSelectedAgentForNewAgent({ - pathname: "/h/srv-1/agent/agent-2", + pathname: "/h/srv-1/workspace/%2Frepo/agent/agent-2", selectedAgentId: "srv-9:agent-9", }) ).toEqual({ diff --git a/packages/app/src/utils/new-agent-routing.ts b/packages/app/src/utils/new-agent-routing.ts index dfe1ff987..6d57b1a19 100644 --- a/packages/app/src/utils/new-agent-routing.ts +++ b/packages/app/src/utils/new-agent-routing.ts @@ -2,6 +2,7 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; import { buildHostAgentDraftRoute, parseHostAgentRouteFromPathname, + parseHostWorkspaceAgentRouteFromPathname, } from "@/utils/host-routes"; export function parseAgentKey( @@ -26,6 +27,10 @@ export function resolveSelectedAgentForNewAgent(input: { pathname: string; selectedAgentId?: string; }): { serverId: string; agentId: string } | null { + const workspaceRoute = parseHostWorkspaceAgentRouteFromPathname(input.pathname); + if (workspaceRoute) { + return { serverId: workspaceRoute.serverId, agentId: workspaceRoute.agentId }; + } return ( parseHostAgentRouteFromPathname(input.pathname) ?? parseAgentKey(input.selectedAgentId) diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index b03ce00df..b4f36d6ee 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -38,7 +38,7 @@ describe("buildNotificationRoute", () => { }); it("falls back to host-scoped draft route when only serverId is present", () => { - expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only/agent"); + expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only/new"); }); it("falls back to root when no server id is present", () => { diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index e70699b8a..055bb12ee 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,6 +1,6 @@ import { + buildHostDraftRoute, buildHostAgentDetailRoute, - buildHostAgentDraftRoute, } from "@/utils/host-routes"; type NotificationData = Record | null | undefined; @@ -33,7 +33,7 @@ export function buildNotificationRoute(data: NotificationData): string { return buildHostAgentDetailRoute(serverId, agentId); } if (serverId) { - return buildHostAgentDraftRoute(serverId); + return buildHostDraftRoute(serverId); } return "/"; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 07e2fc243..ab07ddfe1 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -204,16 +204,20 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null { return `remote:${cleanedHost}/${cleanedPath}` } -function deriveProjectGroupingKey(options: { cwd: string; remoteUrl: string | null }): string { +function deriveProjectGroupingKey(options: { + cwd: string + remoteUrl: string | null + isPaseoOwnedWorktree: boolean + mainRepoRoot: string | null +}): string { const remoteKey = deriveRemoteProjectKey(options.remoteUrl) if (remoteKey) { return remoteKey } - const worktreeMarker = '.paseo/worktrees/' - const idx = options.cwd.indexOf(worktreeMarker) - if (idx !== -1) { - return options.cwd.slice(0, idx).replace(/\/$/, '') + const mainRepoRoot = options.mainRepoRoot?.trim() + if (options.isPaseoOwnedWorktree && mainRepoRoot) { + return mainRepoRoot } return options.cwd @@ -1225,6 +1229,8 @@ export class Session { const projectKey = deriveProjectGroupingKey({ cwd, remoteUrl: checkout.remoteUrl, + isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, + mainRepoRoot: checkout.mainRepoRoot, }) return { projectKey,