feat: projects → workspaces → tabs

This commit is contained in:
Mohamed Boudra
2026-03-01 18:22:11 +07:00
parent 2e201cab09
commit 080fb74642
43 changed files with 4054 additions and 2654 deletions
+138
View File
@@ -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.
+448
View File
@@ -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<projectKey, workspaceId[]>`
- `lastOpenedWorkspaceByProject: Record<projectKey, workspaceId>`
- `lastFocusedTabByWorkspace: Record<workspaceId, tabId>`
- `openTabsByWorkspace: Record<workspaceId, WorkspaceTab[]>` (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.
+15 -3
View File
@@ -45,8 +45,9 @@ import {
} from "@/utils/os-notifications"; } from "@/utils/os-notifications";
import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildNotificationRoute } from "@/utils/notification-routing";
import { import {
buildHostAgentDraftRoute, buildHostDraftRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
import { getTauri } from "@/utils/tauri"; import { getTauri } from "@/utils/tauri";
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics"; import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
@@ -350,7 +351,7 @@ function OfferLinkListener({
if (cancelled) return; if (cancelled) return;
const serverId = (profile as any)?.serverId; const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return; if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostAgentDraftRoute(serverId) as any); router.replace(buildHostDraftRoute(serverId) as any);
}) })
.catch((error) => { .catch((error) => {
if (cancelled) return; if (cancelled) return;
@@ -380,7 +381,9 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
// Parse selectedAgentKey directly from pathname // Parse selectedAgentKey directly from pathname
// useLocalSearchParams doesn't update when navigating between same-pattern routes // useLocalSearchParams doesn't update when navigating between same-pattern routes
const selectedAgentKey = useMemo(() => { const selectedAgentKey = useMemo(() => {
const match = parseHostAgentRouteFromPathname(pathname); const match =
parseHostWorkspaceAgentRouteFromPathname(pathname) ??
parseHostAgentRouteFromPathname(pathname);
return match ? `${match.serverId}:${match.agentId}` : undefined; return match ? `${match.serverId}:${match.agentId}` : undefined;
}, [pathname]); }, [pathname]);
@@ -465,6 +468,15 @@ export default function RootLayout() {
> >
<Stack.Screen name="index" /> <Stack.Screen name="index" />
<Stack.Screen name="settings" /> <Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/new" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/index" />
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen
name="h/[serverId]/workspace/[workspaceId]/terminal/[terminalId]"
/>
<Stack.Screen <Stack.Screen
name="h/[serverId]/agent/[agentId]" name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }} options={{ gestureEnabled: false }}
@@ -1,17 +1,99 @@
import { useLocalSearchParams } from "expo-router"; import { useEffect, useRef } from "react";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import {
buildHostDraftRoute,
buildHostWorkspaceAgentRoute,
} from "@/utils/host-routes";
export default function HostAgentReadyRoute() { export default function HostAgentReadyRoute() {
const router = useRouter();
const params = useLocalSearchParams<{ const params = useLocalSearchParams<{
serverId?: string; serverId?: string;
agentId?: string; agentId?: string;
}>(); }>();
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(() => {
<AgentReadyScreen if (redirectedRef.current) {
serverId={typeof params.serverId === "string" ? params.serverId : ""} return;
agentId={typeof params.agentId === "string" ? params.agentId : ""} }
/> 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;
} }
@@ -1,10 +1,48 @@
import { useLocalSearchParams } from "expo-router"; import { useEffect, useMemo } from "react";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; import { useLocalSearchParams, useRouter } from "expo-router";
import { buildHostDraftRoute } from "@/utils/host-routes";
export default function HostDraftAgentRoute() { 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 : ""; const serverId = typeof params.serverId === "string" ? params.serverId : "";
return <DraftAgentScreen forcedServerId={serverId} />; 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;
}
+32 -4
View File
@@ -1,19 +1,47 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useLocalSearchParams, useRouter } from "expo-router"; 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() { export default function HostIndexRoute() {
const router = useRouter(); const router = useRouter();
const params = useLocalSearchParams<{ serverId?: string }>(); const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : ""; const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sessionAgents = useSessionStore(
(state) => (serverId ? state.sessions[serverId]?.agents : undefined)
);
useEffect(() => { useEffect(() => {
if (!serverId) { if (!serverId) {
return; 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; return null;
} }
@@ -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 <DraftAgentScreen forcedServerId={serverId} />;
}
@@ -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 (
<WorkspaceScreen
serverId={typeof params.serverId === "string" ? params.serverId : ""}
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
routeTab={agentId ? { kind: "agent", agentId } : null}
/>
);
}
@@ -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 (
<WorkspaceScreen
serverId={typeof params.serverId === "string" ? params.serverId : ""}
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
routeTab={null}
/>
);
}
@@ -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 (
<WorkspaceScreen
serverId={typeof params.serverId === "string" ? params.serverId : ""}
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
routeTab={terminalId ? { kind: "terminal", terminalId } : null}
/>
);
}
+1 -2
View File
@@ -5,7 +5,6 @@ import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostAgentDraftRoute } from "@/utils/host-routes";
export default function Index() { export default function Index() {
const router = useRouter(); const router = useRouter();
@@ -45,7 +44,7 @@ export default function Index() {
if (!targetServerId) { if (!targetServerId) {
return; return;
} }
router.replace(buildHostAgentDraftRoute(targetServerId) as any); router.replace(`/h/${encodeURIComponent(targetServerId)}` as any);
}, [preferencesLoading, registryLoading, router, targetServerId]); }, [preferencesLoading, registryLoading, router, targetServerId]);
if (registryLoading || preferencesLoading) { if (registryLoading || preferencesLoading) {
+7 -6
View File
@@ -34,7 +34,6 @@ import {
} from "@/utils/navigation-timing"; } from "@/utils/navigation-timing";
import { import {
buildHostAgentDetailRoute, buildHostAgentDetailRoute,
parseHostAgentRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
interface AgentListProps { interface AgentListProps {
@@ -85,7 +84,7 @@ interface AgentListRowProps {
agent: AggregatedAgent; agent: AggregatedAgent;
selectedAgentId?: string; selectedAgentId?: string;
showCheckoutInfo: boolean; showCheckoutInfo: boolean;
onPress: (serverId: string, agentId: string) => void; onPress: (agent: AggregatedAgent) => void;
onLongPress: (agent: AggregatedAgent) => void; onLongPress: (agent: AggregatedAgent) => void;
} }
@@ -117,7 +116,7 @@ function AgentListRow({
hovered && styles.agentItemHovered, hovered && styles.agentItemHovered,
pressed && styles.agentItemPressed, pressed && styles.agentItemPressed,
]} ]}
onPress={() => onPress(agent.serverId, agent.id)} onPress={() => onPress(agent)}
onLongPress={() => onLongPress(agent)} onLongPress={() => onLongPress(agent)}
testID={`agent-row-${agent.serverId}-${agent.id}`} testID={`agent-row-${agent.serverId}-${agent.id}`}
> >
@@ -169,11 +168,13 @@ export function AgentList({
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback( const handleAgentPress = useCallback(
(serverId: string, agentId: string) => { (agent: AggregatedAgent) => {
if (isActionSheetVisible) { if (isActionSheetVisible) {
return; return;
} }
const serverId = agent.serverId;
const agentId = agent.id;
const navigationKey = buildAgentNavigationKey(serverId, agentId); const navigationKey = buildAgentNavigationKey(serverId, agentId);
startNavigationTiming(navigationKey, { startNavigationTiming(navigationKey, {
from: "home", from: "home",
@@ -181,12 +182,12 @@ export function AgentList({
params: { serverId, agentId }, params: { serverId, agentId },
}); });
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const shouldReplace = pathname.startsWith("/h/");
const navigate = shouldReplace ? router.replace : router.push; const navigate = shouldReplace ? router.replace : router.push;
onAgentSelect?.(); onAgentSelect?.();
navigate(buildHostAgentDetailRoute(serverId, agentId) as any); navigate(buildHostAgentDetailRoute(serverId, agentId, agent.cwd) as any);
}, },
[isActionSheetVisible, pathname, onAgentSelect] [isActionSheetVisible, pathname, onAgentSelect]
); );
@@ -173,8 +173,14 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId) state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId)
); );
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = agent.projectPlacement?.checkout?.cwd ?? workspaceRoot;
const { requestDirectoryListing, requestFilePreview, selectExplorerEntry } = const { requestDirectoryListing, requestFilePreview, selectExplorerEntry } =
useFileExplorerActions(resolvedServerId); useFileExplorerActions({
serverId: resolvedServerId,
workspaceId,
workspaceRoot,
});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes // Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422. // tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android"; const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -204,13 +210,13 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
return; return;
} }
requestDirectoryListing(agentId, normalized.directory, { void requestDirectoryListing(normalized.directory, {
recordHistory: false, recordHistory: false,
setCurrentPath: false, setCurrentPath: false,
}); });
if (normalized.file) { if (normalized.file) {
selectExplorerEntry(agentId, normalized.file); selectExplorerEntry(normalized.file);
requestFilePreview(agentId, normalized.file); void requestFilePreview(normalized.file);
} }
setExplorerTabForCheckout({ setExplorerTabForCheckout({
@@ -223,7 +229,6 @@ export const AgentStreamView = forwardRef<AgentStreamViewHandle, AgentStreamView
}, },
[ [
agent.cwd, agent.cwd,
agentId,
requestDirectoryListing, requestDirectoryListing,
requestFilePreview, requestFilePreview,
selectExplorerEntry, selectExplorerEntry,
@@ -21,7 +21,6 @@ import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animati
import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane"; import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane"; import { FileExplorerPane } from "./file-explorer-pane";
import { TerminalPane } from "./terminal-pane";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
@@ -46,12 +45,17 @@ function resolveKeyboardShift(rawHeight: number, inset: number): number {
interface ExplorerSidebarProps { interface ExplorerSidebarProps {
serverId: string; serverId: string;
agentId: string; workspaceId?: string | null;
cwd: string; workspaceRoot: string;
isGit: boolean; isGit: boolean;
} }
export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSidebarProps) { export function ExplorerSidebar({
serverId,
workspaceId,
workspaceRoot,
isGit,
}: ExplorerSidebarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isScreenFocused = useIsFocused(); const isScreenFocused = useIsFocused();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@@ -121,9 +125,9 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
const handleTabPress = useCallback( const handleTabPress = useCallback(
(tab: ExplorerTab) => { (tab: ExplorerTab) => {
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) // Swipe gesture to close (swipe right on mobile)
@@ -132,7 +136,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
Gesture.Pan() Gesture.Pan()
.withRef(closeGestureRef) .withRef(closeGestureRef)
.enabled(enableSidebarCloseGesture) .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. // unless we detect an intentional right-swipe close.
.manualActivation(true) .manualActivation(true)
.onTouchesDown((event) => { .onTouchesDown((event) => {
@@ -301,8 +305,8 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
onTabPress={handleTabPress} onTabPress={handleTabPress}
onClose={() => handleClose("header-close-button")} onClose={() => handleClose("header-close-button")}
serverId={serverId} serverId={serverId}
agentId={agentId} workspaceId={workspaceId}
cwd={cwd} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isMobile={isMobile} isMobile={isMobile}
/> />
@@ -334,8 +338,8 @@ export function ExplorerSidebar({ serverId, agentId, cwd, isGit }: ExplorerSideb
onTabPress={handleTabPress} onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")} onClose={() => handleClose("desktop-close-button")}
serverId={serverId} serverId={serverId}
agentId={agentId} workspaceId={workspaceId}
cwd={cwd} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isMobile={false} isMobile={false}
/> />
@@ -348,8 +352,8 @@ interface SidebarContentProps {
onTabPress: (tab: ExplorerTab) => void; onTabPress: (tab: ExplorerTab) => void;
onClose: () => void; onClose: () => void;
serverId: string; serverId: string;
agentId: string; workspaceId?: string | null;
cwd: string; workspaceRoot: string;
isGit: boolean; isGit: boolean;
isMobile: boolean; isMobile: boolean;
} }
@@ -359,8 +363,8 @@ function SidebarContent({
onTabPress, onTabPress,
onClose, onClose,
serverId, serverId,
agentId, workspaceId,
cwd, workspaceRoot,
isGit, isGit,
isMobile, isMobile,
}: SidebarContentProps) { }: SidebarContentProps) {
@@ -403,20 +407,6 @@ function SidebarContent({
Files Files
</Text> </Text>
</Pressable> </Pressable>
<Pressable
testID="explorer-tab-terminals"
style={[styles.tab, resolvedTab === "terminals" && styles.tabActive]}
onPress={() => onTabPress("terminals")}
>
<Text
style={[
styles.tabText,
resolvedTab === "terminals" && styles.tabTextActive,
]}
>
Terminals
</Text>
</Pressable>
</View> </View>
<View style={styles.headerRightSection}> <View style={styles.headerRightSection}>
{isMobile && ( {isMobile && (
@@ -430,13 +420,18 @@ function SidebarContent({
{/* Content based on active tab */} {/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area"> <View style={styles.contentArea} testID="explorer-content-area">
{resolvedTab === "changes" && ( {resolvedTab === "changes" && (
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} /> <GitDiffPane
serverId={serverId}
workspaceId={workspaceId}
cwd={workspaceRoot}
/>
)} )}
{resolvedTab === "files" && ( {resolvedTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} /> <FileExplorerPane
)} serverId={serverId}
{resolvedTab === "terminals" && ( workspaceId={workspaceId}
<TerminalPane serverId={serverId} cwd={cwd} /> workspaceRoot={workspaceRoot}
/>
)} )}
</View> </View>
</View> </View>
@@ -60,6 +60,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { import {
usePanelStore, usePanelStore,
DEFAULT_EXPLORER_FILES_SPLIT_RATIO, DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
@@ -81,7 +82,8 @@ const INDENT_PER_LEVEL = 12;
interface FileExplorerPaneProps { interface FileExplorerPaneProps {
serverId: string; serverId: string;
agentId: string; workspaceId?: string | null;
workspaceRoot: string;
} }
interface TreeRow { interface TreeRow {
@@ -89,7 +91,11 @@ interface TreeRow {
depth: number; depth: number;
} }
export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { export function FileExplorerPane({
serverId,
workspaceId,
workspaceRoot,
}: FileExplorerPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -100,23 +106,40 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
() => daemons.find((daemon) => daemon.serverId === serverId), () => daemons.find((daemon) => daemon.serverId === serverId),
[daemons, serverId] [daemons, serverId]
); );
const agentExists = useSessionStore((state) => const normalizedWorkspaceRoot = useMemo(
agentId && state.sessions[serverId] () => workspaceRoot.trim(),
? state.sessions[serverId]?.agents.has(agentId) [workspaceRoot]
: false
); );
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) => const explorerState = useSessionStore((state) =>
agentId && state.sessions[serverId] workspaceStateKey && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(agentId) ? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
: undefined : undefined
); );
const { const {
workspaceStateKey: actionsWorkspaceStateKey,
requestDirectoryListing, requestDirectoryListing,
requestFilePreview, requestFilePreview,
requestFileDownloadToken, requestFileDownloadToken,
selectExplorerEntry, selectExplorerEntry,
} = useFileExplorerActions(serverId); } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
});
const sortOption = usePanelStore((state) => state.explorerSortOption); const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption); const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio); const splitRatio = usePanelStore((state) => state.explorerFilesSplitRatio);
@@ -155,20 +178,29 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const previewSnapPoints = useMemo(() => ["70%", "95%"], []); const previewSnapPoints = useMemo(() => ["70%", "95%"], []);
const hasInitializedRef = useRef(false); const hasInitializedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!agentId || !requestDirectoryListing) { hasInitializedRef.current = false;
setExpandedPaths(new Set(["."]));
}, [actionsWorkspaceStateKey]);
useEffect(() => {
if (!hasWorkspaceScope) {
return; return;
} }
if (hasInitializedRef.current) { if (hasInitializedRef.current) {
return; return;
} }
hasInitializedRef.current = true; hasInitializedRef.current = true;
requestDirectoryListing(agentId, ".", { recordHistory: false, setCurrentPath: false }); void requestDirectoryListing(".", {
}, [agentId, requestDirectoryListing]); recordHistory: false,
setCurrentPath: false,
});
}, [hasWorkspaceScope, requestDirectoryListing]);
// Expand ancestor directories when a file is selected (e.g., from an inline path click) // Expand ancestor directories when a file is selected (e.g., from an inline path click)
useEffect(() => { useEffect(() => {
if (!agentId || !selectedEntryPath || !requestDirectoryListing) { if (!selectedEntryPath || !hasWorkspaceScope) {
return; return;
} }
const parentDir = getParentDirectory(selectedEntryPath); const parentDir = getParentDirectory(selectedEntryPath);
@@ -182,10 +214,13 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
ancestors.forEach((path) => { ancestors.forEach((path) => {
if (!directories.has(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 // Open/close preview sheet based on selection
useEffect(() => { useEffect(() => {
@@ -200,15 +235,12 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
}, [isMobile, selectedEntryPath]); }, [isMobile, selectedEntryPath]);
const handleClosePreview = useCallback(() => { const handleClosePreview = useCallback(() => {
if (!agentId) { selectExplorerEntry(null);
return; }, [selectExplorerEntry]);
}
selectExplorerEntry(agentId, null);
}, [agentId, selectExplorerEntry]);
const handleToggleDirectory = useCallback( const handleToggleDirectory = useCallback(
(entry: ExplorerEntry) => { (entry: ExplorerEntry) => {
if (!agentId || !requestDirectoryListing) { if (!hasWorkspaceScope) {
return; return;
} }
@@ -225,21 +257,24 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
}); });
if (nextExpanded && !directories.has(entry.path)) { 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( const handleOpenFile = useCallback(
(entry: ExplorerEntry) => { (entry: ExplorerEntry) => {
if (!agentId || !requestFilePreview) { if (!hasWorkspaceScope) {
return; return;
} }
selectExplorerEntry(agentId, entry.path); selectExplorerEntry(entry.path);
requestFilePreview(agentId, entry.path); void requestFilePreview(entry.path);
}, },
[agentId, requestFilePreview, selectExplorerEntry] [hasWorkspaceScope, requestFilePreview, selectExplorerEntry]
); );
const handleEntryPress = useCallback( const handleEntryPress = useCallback(
@@ -260,20 +295,26 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
const startDownload = useDownloadStore((state) => state.startDownload); const startDownload = useDownloadStore((state) => state.startDownload);
const handleDownloadEntry = useCallback( const handleDownloadEntry = useCallback(
(entry: ExplorerEntry) => { (entry: ExplorerEntry) => {
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") { if (!workspaceScopeId || entry.kind !== "file") {
return; return;
} }
startDownload({ startDownload({
serverId, serverId,
agentId, scopeId: workspaceScopeId,
fileName: entry.name, fileName: entry.name,
path: entry.path, path: entry.path,
daemonProfile, daemonProfile,
requestFileDownloadToken, requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
}); });
}, },
[agentId, serverId, daemonProfile, requestFileDownloadToken, startDownload] [
daemonProfile,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
]
); );
const handleSortCycle = useCallback(() => { const handleSortCycle = useCallback(() => {
@@ -283,9 +324,9 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
}, [sortOption, setSortOption]); }, [sortOption, setSortOption]);
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({ const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
queryKey: ["fileExplorerRefresh", serverId, agentId], queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
queryFn: async () => { queryFn: async () => {
if (!agentId) { if (!hasWorkspaceScope) {
return null; return null;
} }
@@ -296,12 +337,12 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
await Promise.all([ await Promise.all([
...directoryPaths.map((path) => ...directoryPaths.map((path) =>
requestDirectoryListing(agentId, path, { requestDirectoryListing(path, {
recordHistory: false, recordHistory: false,
setCurrentPath: false, setCurrentPath: false,
}) })
), ),
...(selectedEntryPath ? [requestFilePreview(agentId, selectedEntryPath)] : []), ...(selectedEntryPath ? [requestFilePreview(selectedEntryPath)] : []),
]); ]);
return null; return null;
}, },
@@ -587,15 +628,15 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
); );
const handleBackFromError = useCallback(() => { const handleBackFromError = useCallback(() => {
if (!agentId || !requestDirectoryListing) { if (!hasWorkspaceScope) {
return; return;
} }
selectExplorerEntry(agentId, null); selectExplorerEntry(null);
requestDirectoryListing(agentId, errorRecoveryPath, { void requestDirectoryListing(errorRecoveryPath, {
recordHistory: false, recordHistory: false,
setCurrentPath: true, setCurrentPath: true,
}); });
}, [agentId, errorRecoveryPath, requestDirectoryListing, selectExplorerEntry]); }, [errorRecoveryPath, hasWorkspaceScope, requestDirectoryListing, selectExplorerEntry]);
const handleTreeListScroll = useCallback( const handleTreeListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (event: NativeSyntheticEvent<NativeScrollEvent>) => {
@@ -615,10 +656,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
[showDesktopWebScrollbar, treeScrollbarMetrics] [showDesktopWebScrollbar, treeScrollbarMetrics]
); );
if (!agentExists) { if (!hasWorkspaceScope) {
return ( return (
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.errorText}>Agent not found</Text> <Text style={styles.errorText}>Workspace is unavailable</Text>
</View> </View>
); );
} }
@@ -640,12 +681,10 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
<Pressable <Pressable
style={styles.retryButton} style={styles.retryButton}
onPress={() => { onPress={() => {
if (agentId) { void requestDirectoryListing(".", {
requestDirectoryListing(agentId, ".", { recordHistory: false,
recordHistory: false, setCurrentPath: false,
setCurrentPath: false, });
});
}
}} }}
> >
<Text style={styles.retryButtonText}>Retry</Text> <Text style={styles.retryButtonText}>Retry</Text>
@@ -464,7 +464,7 @@ function DiffFileBody({
interface GitDiffPaneProps { interface GitDiffPaneProps {
serverId: string; serverId: string;
agentId: string; workspaceId?: string | null;
cwd: string; cwd: string;
} }
@@ -472,7 +472,7 @@ type DiffFlatItem =
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
| { type: "body"; file: ParsedDiffFile; fileIndex: number }; | { type: "body"; file: ParsedDiffFile; fileIndex: number };
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -750,7 +750,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
perfLog(DIFF_PANE_LOG_TAG, { perfLog(DIFF_PANE_LOG_TAG, {
event: "files_snapshot", event: "files_snapshot",
serverId, serverId,
agentId, workspaceId: workspaceId ?? cwd,
fileCount: diffMetrics.fileCount, fileCount: diffMetrics.fileCount,
hunkCount: diffMetrics.hunkCount, hunkCount: diffMetrics.hunkCount,
lineCount: diffMetrics.lineCount, lineCount: diffMetrics.lineCount,
@@ -758,7 +758,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
isLoading: isDiffLoading, isLoading: isDiffLoading,
isFetching: isDiffFetching, isFetching: isDiffFetching,
}); });
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]); }, [cwd, diffMetrics, isDiffFetching, isDiffLoading, serverId, workspaceId]);
const commitStatus = useCheckoutGitActionsStore((state) => const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }) state.getStatus({ serverId, cwd, actionId: "commit" })
+174 -336
View File
@@ -1,70 +1,62 @@
import { import { useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react'
useCallback, import { View, Pressable, Text, Platform } from 'react-native'
useMemo, import { useSafeAreaInsets } from 'react-native-safe-area-context'
useState,
useEffect,
useRef,
useSyncExternalStore,
} from "react";
import { View, Pressable, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, { import Animated, {
useAnimatedStyle, useAnimatedStyle,
interpolate, interpolate,
Extrapolation, Extrapolation,
runOnJS, runOnJS,
} from "react-native-reanimated"; } from 'react-native-reanimated'
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { Plus, ListFilter, Settings, Users } from "lucide-react-native"; import { Plus, Settings, Users } from 'lucide-react-native'
import { router, usePathname } from "expo-router"; import { router, usePathname } from 'expo-router'
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from '@/stores/panel-store'
import { SidebarAgentList } from "./sidebar-agent-list"; import { SidebarAgentList } from './sidebar-agent-list'
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton'
import { useSidebarAgentsList } from "@/hooks/use-sidebar-agents-list"; import { useSidebarAgentsList } from '@/hooks/use-sidebar-agents-list'
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useSidebarAnimation } from '@/contexts/sidebar-animation-context'
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-window'
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { Combobox } from "@/components/ui/combobox"; import { Combobox } from '@/components/ui/combobox'
import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { getHostRuntimeStore } from '@/runtime/host-runtime'
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from '@/stores/session-store'
import { formatConnectionStatus } from "@/utils/daemons"; import { formatConnectionStatus } from '@/utils/daemons'
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout'
import { import {
checkoutStatusQueryKey, checkoutStatusQueryKey,
type CheckoutStatusPayload, type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query"; } from '@/hooks/use-checkout-status-query'
import { queryClient } from "@/query/query-client"; import { queryClient } from '@/query/query-client'
import { import {
buildNewAgentRoute, buildNewAgentRoute,
resolveNewAgentWorkingDir, resolveNewAgentWorkingDir,
resolveSelectedAgentForNewAgent, resolveSelectedAgentForNewAgent,
} from "@/utils/new-agent-routing"; } from '@/utils/new-agent-routing'
import { import {
buildHostAgentsRoute, buildHostAgentsRoute,
buildHostSettingsRoute, buildHostSettingsRoute,
mapPathnameToServer, mapPathnameToServer,
parseServerIdFromPathname, parseServerIdFromPathname,
} from "@/utils/host-routes"; } from '@/utils/host-routes'
const DESKTOP_SIDEBAR_WIDTH = 320; const DESKTOP_SIDEBAR_WIDTH = 320
interface LeftSidebarProps { interface LeftSidebarProps {
selectedAgentId?: string; selectedAgentId?: string
} }
export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles()
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets()
const isMobile = const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView)
const mobileView = usePanelStore((state) => state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen)
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const closeToAgent = usePanelStore((state) => state.closeToAgent)
const closeToAgent = usePanelStore((state) => state.closeToAgent); const pathname = usePathname()
const pathname = usePathname(); const { daemons } = useDaemonRegistry()
const { daemons } = useDaemonRegistry(); const runtime = getHostRuntimeStore()
const runtime = getHostRuntimeStore();
const runtimeConnectionStatusSignature = useSyncExternalStore( const runtimeConnectionStatusSignature = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange), (onStoreChange) => runtime.subscribeAll(onStoreChange),
() => () =>
@@ -72,76 +64,58 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
.map( .map(
(daemon) => (daemon) =>
`${daemon.serverId}:${ `${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}` }`
) )
.join("|"), .join('|'),
() => () =>
daemons daemons
.map( .map(
(daemon) => (daemon) =>
`${daemon.serverId}:${ `${daemon.serverId}:${
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
}` }`
) )
.join("|") .join('|')
); )
const activeServerIdFromPath = useMemo( const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname])
() => parseServerIdFromPathname(pathname), const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null
[pathname]
);
const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null;
const activeHostLabel = useMemo(() => { const activeHostLabel = useMemo(() => {
if (!activeServerId) return "No host"; if (!activeServerId) return 'No host'
const daemon = daemons.find((entry) => entry.serverId === activeServerId); const daemon = daemons.find((entry) => entry.serverId === activeServerId)
const trimmed = daemon?.label?.trim(); const trimmed = daemon?.label?.trim()
return trimmed && trimmed.length > 0 ? trimmed : activeServerId; return trimmed && trimmed.length > 0 ? trimmed : activeServerId
}, [activeServerId, daemons]); }, [activeServerId, daemons])
const activeHostStatus = activeServerId const activeHostStatus = activeServerId
? runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting" ? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting')
: "idle"; : 'idle'
const activeHostStatusColor = const activeHostStatusColor =
activeHostStatus === "online" activeHostStatus === 'online'
? theme.colors.palette.green[400] ? theme.colors.palette.green[400]
: activeHostStatus === "connecting" : activeHostStatus === 'connecting'
? theme.colors.palette.amber[500] ? theme.colors.palette.amber[500]
: theme.colors.palette.red[500]; : theme.colors.palette.red[500]
const hostOptions = useMemo( const hostOptions = useMemo(
() => () =>
daemons.map((daemon) => ({ daemons.map((daemon) => ({
id: daemon.serverId, id: daemon.serverId,
label: daemon.label?.trim() || daemon.serverId, label: daemon.label?.trim() || daemon.serverId,
description: formatConnectionStatus( description: formatConnectionStatus(
runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting'
), ),
})), })),
[daemons, runtime, runtimeConnectionStatusSignature] [daemons, runtime, runtimeConnectionStatusSignature]
); )
const hostTriggerRef = useRef<View>(null); const hostTriggerRef = useRef<View>(null)
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); const [isHostPickerOpen, setIsHostPickerOpen] = useState(false)
const projectFilterAnchorRef = useRef<View>(null);
const [isProjectFilterOpen, setIsProjectFilterOpen] = useState(false);
// Derive isOpen from the unified panel state // Derive isOpen from the unified panel state
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen
const [selectedProjectFilterKey, setSelectedProjectFilterKey] = useState<string | null>(
null
);
const { const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarAgentsList({
entries,
projectFilterOptions,
hasMoreEntries,
isInitialLoad,
isRevalidating,
refreshAll,
} = useSidebarAgentsList({
serverId: activeServerId, serverId: activeServerId,
selectedProjectFilterKey, enabled: isOpen,
}); })
useEffect(() => {
setSelectedProjectFilterKey(null);
}, [activeServerId]);
const { const {
translateX, translateX,
backdropOpacity, backdropOpacity,
@@ -150,157 +124,116 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
animateToClose, animateToClose,
isGesturing, isGesturing,
closeGestureRef, closeGestureRef,
} = useSidebarAnimation(); } = useSidebarAnimation()
const dragHandlers = useTauriDragHandlers(); const dragHandlers = useTauriDragHandlers()
const trafficLightPadding = useTrafficLightPadding(); const trafficLightPadding = useTrafficLightPadding()
// Track user-initiated refresh to avoid showing spinner on background revalidation // Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false); const [isManualRefresh, setIsManualRefresh] = useState(false)
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
setIsManualRefresh(true); setIsManualRefresh(true)
refreshAll(); refreshAll()
}, [refreshAll]); }, [refreshAll])
// Reset manual refresh flag when revalidation completes // Reset manual refresh flag when revalidation completes
useEffect(() => { useEffect(() => {
if (!isRevalidating && isManualRefresh) { if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false); setIsManualRefresh(false)
} }
}, [isRevalidating, isManualRefresh]); }, [isRevalidating, isManualRefresh])
const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore( const setSidebarShortcutAgentKeys = useKeyboardShortcutsStore(
(s) => s.setSidebarShortcutAgentKeys (s) => s.setSidebarShortcutAgentKeys
); )
const sidebarShortcutAgentKeys = useMemo(() => {
return entries.slice(0, 9).map((entry) => `${entry.agent.serverId}:${entry.agent.id}`);
}, [entries]);
useEffect(() => { useEffect(() => {
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys); setSidebarShortcutAgentKeys([])
}, [setSidebarShortcutAgentKeys, sidebarShortcutAgentKeys]); }, [setSidebarShortcutAgentKeys])
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
closeToAgent(); closeToAgent()
}, [closeToAgent]); }, [closeToAgent])
const handleCreateAgentClean = useCallback(() => { const handleCreateAgentClean = useCallback(() => {
let targetServerId = activeServerId; let targetServerId = activeServerId
let targetWorkingDir: string | null = null; let targetWorkingDir: string | null = null
const selectedAgent = resolveSelectedAgentForNewAgent({ const selectedAgent = resolveSelectedAgentForNewAgent({
pathname, pathname,
selectedAgentId, selectedAgentId,
}); })
if (selectedAgent) { if (selectedAgent) {
targetServerId = selectedAgent.serverId; targetServerId = selectedAgent.serverId
const agent = useSessionStore const agent = useSessionStore
.getState() .getState()
.sessions[selectedAgent.serverId] .sessions[selectedAgent.serverId]?.agents?.get(selectedAgent.agentId)
?.agents?.get(selectedAgent.agentId); const cwd = agent?.cwd?.trim()
const cwd = agent?.cwd?.trim();
if (cwd) { if (cwd) {
const checkout = const checkout =
queryClient.getQueryData<CheckoutStatusPayload>( queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(selectedAgent.serverId, cwd) checkoutStatusQueryKey(selectedAgent.serverId, cwd)
) ?? null; ) ?? null
targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout); targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout)
} }
} }
if (!targetServerId) { if (!targetServerId) {
return; return
} }
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any); router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any)
}, [activeServerId, pathname, selectedAgentId]); }, [activeServerId, pathname, selectedAgentId])
// Mobile: close sidebar and navigate // Mobile: close sidebar and navigate
const handleCreateAgentCleanMobile = useCallback(() => { const handleCreateAgentCleanMobile = useCallback(() => {
closeToAgent(); closeToAgent()
handleCreateAgentClean(); handleCreateAgentClean()
}, [closeToAgent, handleCreateAgentClean]); }, [closeToAgent, handleCreateAgentClean])
// Desktop: just navigate, don't close // Desktop: just navigate, don't close
const handleCreateAgentCleanDesktop = useCallback(() => { const handleCreateAgentCleanDesktop = useCallback(() => {
handleCreateAgentClean(); handleCreateAgentClean()
}, [handleCreateAgentClean]); }, [handleCreateAgentClean])
// Mobile: close sidebar and navigate // Mobile: close sidebar and navigate
const handleSettingsMobile = useCallback(() => { const handleSettingsMobile = useCallback(() => {
if (!activeServerId) { if (!activeServerId) {
return; return
} }
closeToAgent(); closeToAgent()
router.push(buildHostSettingsRoute(activeServerId) as any); router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId, closeToAgent]); }, [activeServerId, closeToAgent])
// Desktop: just navigate, don't close // Desktop: just navigate, don't close
const handleSettingsDesktop = useCallback(() => { const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) { if (!activeServerId) {
return; return
} }
router.push(buildHostSettingsRoute(activeServerId) as any); router.push(buildHostSettingsRoute(activeServerId) as any)
}, [activeServerId]); }, [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]);
const handleViewMore = useCallback(() => { const handleViewMore = useCallback(() => {
if (!activeServerId) { if (!activeServerId) {
return; return
} }
if (isMobile) { if (isMobile) {
translateX.value = -windowWidth; translateX.value = -windowWidth
backdropOpacity.value = 0; backdropOpacity.value = 0
closeToAgent(); closeToAgent()
} }
router.push(buildHostAgentsRoute(activeServerId) as any); router.push(buildHostAgentsRoute(activeServerId) as any)
}, [ }, [activeServerId, backdropOpacity, closeToAgent, isMobile, translateX, windowWidth])
activeServerId,
backdropOpacity,
closeToAgent,
isMobile,
translateX,
windowWidth,
]);
const listFooterComponent = useMemo(() => {
if (!hasMoreEntries) {
return null;
}
return (
<Pressable style={styles.listViewMoreButton} onPress={handleViewMore}>
{({ hovered }) => (
<Text
style={[
styles.listViewMoreButtonText,
hovered && styles.listViewMoreButtonTextHovered,
]}
>
View more
</Text>
)}
</Pressable>
);
}, [handleViewMore, hasMoreEntries]);
const handleHostSelect = useCallback( const handleHostSelect = useCallback(
(nextServerId: string) => { (nextServerId: string) => {
if (!nextServerId) { if (!nextServerId) {
return; return
} }
const nextPath = mapPathnameToServer(pathname, nextServerId); const nextPath = mapPathnameToServer(pathname, nextServerId)
setIsHostPickerOpen(false); setIsHostPickerOpen(false)
router.push(nextPath as any); router.push(nextPath as any)
}, },
[pathname] [pathname]
); )
// Close gesture (swipe left to close when sidebar is open) // Close gesture (swipe left to close when sidebar is open)
// Only activates on leftward swipe, fails on rightward or vertical movement // 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) // Fail if vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10]) .failOffsetY([-10, 10])
.onStart(() => { .onStart(() => {
isGesturing.value = true; isGesturing.value = true
}) })
.onUpdate((event) => { .onUpdate((event) => {
if (!isMobile) return; if (!isMobile) return
// Only allow swiping left (closing) // Only allow swiping left (closing)
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)); const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX))
translateX.value = newTranslateX; translateX.value = newTranslateX
backdropOpacity.value = interpolate( backdropOpacity.value = interpolate(
newTranslateX, newTranslateX,
[-windowWidth, 0], [-windowWidth, 0],
[0, 1], [0, 1],
Extrapolation.CLAMP Extrapolation.CLAMP
); )
}) })
.onEnd((event) => { .onEnd((event) => {
isGesturing.value = false; isGesturing.value = false
if (!isMobile) return; if (!isMobile) return
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
if (shouldClose) { if (shouldClose) {
animateToClose(); animateToClose()
runOnJS(handleClose)(); runOnJS(handleClose)()
} else { } else {
animateToOpen(); animateToOpen()
} }
}) })
.onFinalize(() => { .onFinalize(() => {
isGesturing.value = false; isGesturing.value = false
}); })
const sidebarAnimatedStyle = useAnimatedStyle(() => ({ const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }], transform: [{ translateX: translateX.value }],
})); }))
const backdropAnimatedStyle = useAnimatedStyle(() => ({ const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value, opacity: backdropOpacity.value,
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none',
})); }))
// Render mobile sidebar // Render mobile sidebar
// On web, keep the overlay interactive only while the sidebar is open. // On web, keep the overlay interactive only while the sidebar is open.
// This preserves swipe/scroll behavior without blocking taps when closed. // 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) { if (isMobile) {
return ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}> <View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
@@ -391,55 +322,30 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
size={theme.iconSize.md} size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text> <Text
style={[
styles.newAgentButtonText,
hovered && styles.newAgentButtonTextHovered,
]}
>
New agent
</Text>
</> </>
)} )}
</Pressable> </Pressable>
{projectFilterOptions.length > 0 ? (
<Pressable
ref={projectFilterAnchorRef}
style={[
styles.filterButton,
selectedProjectFilterKey ? styles.filterButtonActive : null,
]}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered }) => (
<ListFilter
size={theme.iconSize.md}
color={
selectedProjectFilterKey
? theme.colors.accentForeground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
) : null}
</View> </View>
</View> </View>
{/* Middle: scrollable agent list */} {/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? ( {isInitialLoad ? (
<SidebarAgentListSkeleton /> <SidebarAgentListSkeleton />
) : ( ) : (
<SidebarAgentList <SidebarAgentList
isOpen={isOpen} isOpen={isOpen}
serverId={activeServerId} serverId={activeServerId}
entries={entries} projects={projects}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKey={selectedProjectFilterKey}
onSelectedProjectFilterKeyChange={setSelectedProjectFilterKey}
isProjectFilterOpen={isProjectFilterOpen}
onProjectFilterOpenChange={setIsProjectFilterOpen}
projectFilterAnchorRef={projectFilterAnchorRef}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
selectedAgentId={selectedAgentId}
onAgentSelect={handleAgentSelectMobile}
parentGestureRef={closeGestureRef} parentGestureRef={closeGestureRef}
/> />
)} )}
@@ -457,10 +363,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
disabled={hostOptions.length === 0} disabled={hostOptions.length === 0}
> >
<View <View
style={[ style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]}
styles.hostStatusDot,
{ backgroundColor: activeHostStatusColor },
]}
/> />
<Text style={styles.hostTriggerText} numberOfLines={1}> <Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel} {activeHostLabel}
@@ -505,7 +408,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
</View> </View>
<Combobox <Combobox
options={hostOptions} options={hostOptions}
value={activeServerId ?? ""} value={activeServerId ?? ''}
onSelect={handleHostSelect} onSelect={handleHostSelect}
searchable={false} searchable={false}
title="Switch host" title="Switch host"
@@ -519,12 +422,12 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
</Animated.View> </Animated.View>
</GestureDetector> </GestureDetector>
</View> </View>
); )
} }
// Desktop: no edge swipe, just show/hide based on isOpen // Desktop: no edge swipe, just show/hide based on isOpen
if (!isOpen) { if (!isOpen) {
return null; return null
} }
return ( return (
@@ -532,10 +435,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
{trafficLightPadding.top > 0 ? ( {trafficLightPadding.top > 0 ? (
<View style={{ height: trafficLightPadding.top }} {...dragHandlers} /> <View style={{ height: trafficLightPadding.top }} {...dragHandlers} />
) : null} ) : null}
<View <View style={styles.sidebarHeader} {...dragHandlers}>
style={styles.sidebarHeader}
{...dragHandlers}
>
<View style={styles.sidebarHeaderRow}> <View style={styles.sidebarHeaderRow}>
<Pressable <Pressable
style={styles.newAgentButton} style={styles.newAgentButton}
@@ -548,54 +448,27 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
size={theme.iconSize.md} size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/> />
<Text style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}>New agent</Text> <Text
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
>
New agent
</Text>
</> </>
)} )}
</Pressable> </Pressable>
{projectFilterOptions.length > 0 ? (
<Pressable
ref={projectFilterAnchorRef}
style={[
styles.filterButton,
selectedProjectFilterKey ? styles.filterButtonActive : null,
]}
onPress={() => setIsProjectFilterOpen(true)}
>
{({ hovered }) => (
<ListFilter
size={theme.iconSize.md}
color={
selectedProjectFilterKey
? theme.colors.accentForeground
: hovered
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
) : null}
</View> </View>
</View> </View>
{/* Middle: scrollable agent list */} {/* Middle: scrollable project/workspace tree */}
{isInitialLoad ? ( {isInitialLoad ? (
<SidebarAgentListSkeleton /> <SidebarAgentListSkeleton />
) : ( ) : (
<SidebarAgentList <SidebarAgentList
isOpen={isOpen} isOpen={isOpen}
serverId={activeServerId} serverId={activeServerId}
entries={entries} projects={projects}
projectFilterOptions={projectFilterOptions}
selectedProjectFilterKey={selectedProjectFilterKey}
onSelectedProjectFilterKeyChange={setSelectedProjectFilterKey}
isProjectFilterOpen={isProjectFilterOpen}
onProjectFilterOpenChange={setIsProjectFilterOpen}
projectFilterAnchorRef={projectFilterAnchorRef}
isRefreshing={isManualRefresh && isRevalidating} isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh} onRefresh={handleRefresh}
listFooterComponent={listFooterComponent}
selectedAgentId={selectedAgentId}
/> />
)} )}
@@ -611,12 +484,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
onPress={() => setIsHostPickerOpen(true)} onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0} disabled={hostOptions.length === 0}
> >
<View <View style={[styles.hostStatusDot, { backgroundColor: activeHostStatusColor }]} />
style={[
styles.hostStatusDot,
{ backgroundColor: activeHostStatusColor },
]}
/>
<Text style={styles.hostTriggerText} numberOfLines={1}> <Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel} {activeHostLabel}
</Text> </Text>
@@ -660,7 +528,7 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
</View> </View>
<Combobox <Combobox
options={hostOptions} options={hostOptions}
value={activeServerId ?? ""} value={activeServerId ?? ''}
onSelect={handleHostSelect} onSelect={handleHostSelect}
searchable={false} searchable={false}
title="Switch host" title="Switch host"
@@ -671,29 +539,29 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
/> />
</View> </View>
</View> </View>
); )
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
backdrop: { backdrop: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)", backgroundColor: 'rgba(0, 0, 0, 0.5)',
}, },
backdropPressable: { backdropPressable: {
flex: 1, flex: 1,
}, },
mobileSidebar: { mobileSidebar: {
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
bottom: 0, bottom: 0,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
overflow: "hidden", overflow: 'hidden',
}, },
sidebarContent: { sidebarContent: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
overflow: "hidden", overflow: 'hidden',
}, },
desktopSidebar: { desktopSidebar: {
borderRightWidth: 1, borderRightWidth: 1,
@@ -706,36 +574,25 @@ const styles = StyleSheet.create((theme) => ({
md: HEADER_INNER_HEIGHT, md: HEADER_INNER_HEIGHT,
}, },
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
justifyContent: "center", justifyContent: 'center',
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
userSelect: "none", userSelect: 'none',
}, },
sidebarHeaderRow: { sidebarHeaderRow: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
newAgentButton: { newAgentButton: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
gap: theme.spacing[2], gap: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
flexShrink: 0, 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: {}, newAgentButtonHovered: {},
newAgentButtonText: { newAgentButtonText: {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
@@ -746,9 +603,9 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
hostTrigger: { hostTrigger: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
justifyContent: "flex-start", justifyContent: 'flex-start',
gap: theme.spacing[2], gap: theme.spacing[2],
minWidth: 0, minWidth: 0,
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
@@ -773,9 +630,9 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0, minWidth: 0,
}, },
sidebarFooter: { sidebarFooter: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
borderTopWidth: 1, borderTopWidth: 1,
@@ -788,38 +645,19 @@ const styles = StyleSheet.create((theme) => ({
marginRight: theme.spacing[2], marginRight: theme.spacing[2],
}, },
footerIconRow: { footerIconRow: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
gap: theme.spacing[2], gap: theme.spacing[2],
flexShrink: 0, flexShrink: 0,
}, },
footerIconButton: { footerIconButton: {
width: 28, width: 28,
height: 28, height: 28,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
paddingHorizontal: 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: { hostPickerList: {
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
@@ -842,10 +680,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
alignItems: "center", alignItems: 'center',
}, },
hostPickerCancelText: { hostPickerCancelText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
})); }))
File diff suppressed because it is too large Load Diff
+162 -110
View File
@@ -44,6 +44,10 @@ import TerminalEmulator from "./terminal-emulator";
interface TerminalPaneProps { interface TerminalPaneProps {
serverId: string; serverId: string;
cwd: string; cwd: string;
selectedTerminalId?: string | null;
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
hideHeader?: boolean;
manageTerminalDirectorySubscription?: boolean;
} }
const MAX_OUTPUT_CHARS = 200_000; 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 { theme } = useUnistyles();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -145,8 +156,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const streamControllerRef = useRef<TerminalStreamController | null>(null); const streamControllerRef = useRef<TerminalStreamController | null>(null);
const outputPumpRef = useRef<TerminalOutputPump | null>(null); const outputPumpRef = useRef<TerminalOutputPump | null>(null);
const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null); const outputDeliveryQueueRef = useRef<TerminalOutputDeliveryQueue | null>(null);
const isSelectedTerminalControlled =
typeof selectedTerminalIdProp !== "undefined";
const [selectedTerminalId, setSelectedTerminalId] = useState<string | null>(null); const [uncontrolledSelectedTerminalId, setUncontrolledSelectedTerminalId] =
useState<string | null>(null);
const selectedTerminalId = isSelectedTerminalControlled
? selectedTerminalIdProp ?? null
: uncontrolledSelectedTerminalId;
const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({ const [selectedOutputChunk, setSelectedOutputChunk] = useState<TerminalOutputChunkState>({
sequence: 0, sequence: 0,
text: "", text: "",
@@ -182,9 +199,13 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
? (next as (value: string | null) => string | null)(current) ? (next as (value: string | null) => string | null)(current)
: next; : next;
selectedTerminalIdRef.current = resolved; selectedTerminalIdRef.current = resolved;
setSelectedTerminalId(resolved); if (isSelectedTerminalControlled) {
onSelectedTerminalIdChange?.(resolved);
return;
}
setUncontrolledSelectedTerminalId(resolved);
}, },
[] [isSelectedTerminalControlled, onSelectedTerminalIdChange]
); );
useEffect(() => { useEffect(() => {
@@ -344,7 +365,12 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}, [client, isConnected, queryClient, terminalsQueryKey]); }, [client, isConnected, queryClient, terminalsQueryKey]);
useEffect(() => { useEffect(() => {
if (!client || !isConnected || !cwd.startsWith("/")) { if (
!manageTerminalDirectorySubscription ||
!client ||
!isConnected ||
!cwd.startsWith("/")
) {
return; return;
} }
@@ -370,7 +396,14 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
unsubscribe(); unsubscribe();
client.unsubscribeTerminals({ cwd }); client.unsubscribeTerminals({ cwd });
}; };
}, [client, cwd, isConnected, queryClient, terminalsQueryKey]); }, [
client,
cwd,
isConnected,
manageTerminalDirectorySubscription,
queryClient,
terminalsQueryKey,
]);
const createTerminalMutation = useMutation({ const createTerminalMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
@@ -394,7 +427,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`, requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
}; };
}); });
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id); if (!isSelectedTerminalControlled) {
selectedTerminalByScopeRef.current.set(scopeKey, createdTerminal.id);
}
updateSelectedTerminalId(createdTerminal.id); updateSelectedTerminalId(createdTerminal.id);
requestTerminalFocus(); requestTerminalFocus();
} }
@@ -435,15 +470,20 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
}); });
useEffect(() => { useEffect(() => {
updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null); if (!isSelectedTerminalControlled) {
updateSelectedTerminalId(selectedTerminalByScopeRef.current.get(scopeKey) ?? null);
}
lastReportedSizeRef.current = null; lastReportedSizeRef.current = null;
}, [scopeKey, updateSelectedTerminalId]); }, [isSelectedTerminalControlled, scopeKey, updateSelectedTerminalId]);
useEffect(() => { useEffect(() => {
if (isSelectedTerminalControlled) {
return;
}
if (selectedTerminalId) { if (selectedTerminalId) {
selectedTerminalByScopeRef.current.set(scopeKey, selectedTerminalId); selectedTerminalByScopeRef.current.set(scopeKey, selectedTerminalId);
} }
}, [scopeKey, selectedTerminalId]); }, [isSelectedTerminalControlled, scopeKey, selectedTerminalId]);
useEffect(() => { useEffect(() => {
if (terminals.length === 0) { if (terminals.length === 0) {
@@ -458,7 +498,9 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return; return;
} }
const stored = selectedTerminalByScopeRef.current.get(scopeKey); const stored = isSelectedTerminalControlled
? null
: selectedTerminalByScopeRef.current.get(scopeKey);
if (has(stored)) { if (has(stored)) {
updateSelectedTerminalId(stored!); updateSelectedTerminalId(stored!);
return; return;
@@ -466,10 +508,18 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
const fallback = terminals[0]?.id ?? null; const fallback = terminals[0]?.id ?? null;
if (fallback) { if (fallback) {
selectedTerminalByScopeRef.current.set(scopeKey, fallback); if (!isSelectedTerminalControlled) {
selectedTerminalByScopeRef.current.set(scopeKey, fallback);
}
updateSelectedTerminalId(fallback); updateSelectedTerminalId(fallback);
} }
}, [scopeKey, terminals, selectedTerminalId, updateSelectedTerminalId]); }, [
isSelectedTerminalControlled,
scopeKey,
terminals,
selectedTerminalId,
updateSelectedTerminalId,
]);
useEffect(() => { useEffect(() => {
const terminalIds = terminals.map((terminal) => terminal.id); const terminalIds = terminals.map((terminal) => terminal.id);
@@ -911,108 +961,110 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.header} testID="terminals-header"> {!hideHeader ? (
<ScrollView <View style={styles.header} testID="terminals-header">
horizontal <ScrollView
style={styles.tabsScroll} horizontal
contentContainerStyle={styles.tabsContent} style={styles.tabsScroll}
showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabsContent}
> showsHorizontalScrollIndicator={false}
{terminals.map((terminal) => { >
const isActive = terminal.id === selectedTerminalId; {terminals.map((terminal) => {
const isTabHovered = hoveredTerminalId === terminal.id; const isActive = terminal.id === selectedTerminalId;
const isCloseHovered = hoveredCloseTerminalId === terminal.id; const isTabHovered = hoveredTerminalId === terminal.id;
const isClosingTerminal = const isCloseHovered = hoveredCloseTerminalId === terminal.id;
killTerminalMutation.isPending && const isClosingTerminal =
killTerminalMutation.variables === terminal.id; killTerminalMutation.isPending &&
const shouldShowCloseButton = killTerminalMutation.variables === terminal.id;
isTabHovered || isCloseHovered || isClosingTerminal; const shouldShowCloseButton =
const gradientId = `terminal-close-gradient-${terminal.id.replace( isTabHovered || isCloseHovered || isClosingTerminal;
/[^a-zA-Z0-9_-]/g, const gradientId = `terminal-close-gradient-${terminal.id.replace(
"-" /[^a-zA-Z0-9_-]/g,
)}`; "-"
return ( )}`;
<Pressable return (
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,
]}
>
<Text
style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}
numberOfLines={1}
ellipsizeMode="tail"
>
{terminal.name}
</Text>
<Pressable <Pressable
testID={`terminal-close-${terminal.id}`} key={terminal.id}
pointerEvents={shouldShowCloseButton ? "auto" : "none"} testID={`terminal-tab-${terminal.id}`}
disabled={!shouldShowCloseButton || isClosingTerminal} onPress={() => updateSelectedTerminalId(terminal.id)}
onHoverIn={() => handleTerminalCloseHoverIn(terminal.id)} onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)} onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
onPress={(event) => { style={({ pressed, hovered }) => [
event.stopPropagation(); styles.terminalTab,
void handleCloseTerminal(terminal.id); isActive && styles.terminalTabActive,
}} shouldShowCloseButton && styles.terminalTabHovered,
style={({ hovered, pressed }) => [ (pressed || hovered) && styles.terminalTabHovered,
styles.terminalTabCloseButton,
shouldShowCloseButton
? styles.terminalTabCloseButtonShown
: styles.terminalTabCloseButtonHidden,
]} ]}
> >
{({ hovered = false, pressed = false }) => { <Text
const iconColor = style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}
hovered || pressed numberOfLines={1}
? theme.colors.foreground ellipsizeMode="tail"
: theme.colors.foregroundMuted; >
return ( {terminal.name}
<> </Text>
<TerminalCloseGradient <Pressable
color={theme.colors.surface2} testID={`terminal-close-${terminal.id}`}
gradientId={gradientId} pointerEvents={shouldShowCloseButton ? "auto" : "none"}
/> disabled={!shouldShowCloseButton || isClosingTerminal}
<View style={styles.terminalTabCloseIcon}> onHoverIn={() => handleTerminalCloseHoverIn(terminal.id)}
{isClosingTerminal ? ( onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
<ActivityIndicator size={12} color={iconColor} /> onPress={(event) => {
) : ( event.stopPropagation();
<X size={12} color={iconColor} /> void handleCloseTerminal(terminal.id);
)} }}
</View> 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 (
<>
<TerminalCloseGradient
color={theme.colors.surface2}
gradientId={gradientId}
/>
<View style={styles.terminalTabCloseIcon}>
{isClosingTerminal ? (
<ActivityIndicator size={12} color={iconColor} />
) : (
<X size={12} color={iconColor} />
)}
</View>
</>
);
}}
</Pressable>
</Pressable> </Pressable>
</Pressable> );
); })}
})} </ScrollView>
</ScrollView> <View style={styles.headerActions}>
<View style={styles.headerActions}> <Pressable
<Pressable testID="terminals-create-button"
testID="terminals-create-button" onPress={handleCreateTerminal}
onPress={handleCreateTerminal} disabled={isCreating}
disabled={isCreating} style={({ hovered, pressed }) => [
style={({ hovered, pressed }) => [ styles.headerIconButton,
styles.headerIconButton, (hovered || pressed) && styles.headerIconButtonHovered,
(hovered || pressed) && styles.headerIconButtonHovered, ]}
]} >
> {isCreating ? (
{isCreating ? ( <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> ) : (
) : ( <Plus size={16} color={theme.colors.foregroundMuted} />
<Plus size={16} color={theme.colors.foregroundMuted} /> )}
)} </Pressable>
</Pressable> </View>
</View> </View>
</View> ) : null}
<View style={styles.outputContainer}> <View style={styles.outputContainer}>
{selectedTerminal ? ( {selectedTerminal ? (
+1 -269
View File
@@ -1,6 +1,6 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react"; import { useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
import { AppState, Platform } from "react-native"; 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 { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity"; import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; 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 { interface SessionProviderSharedProps {
children: ReactNode; children: ReactNode;
serverId: string; serverId: string;
@@ -264,7 +243,6 @@ function SessionProviderInternal({
const setPendingPermissions = useSessionStore( const setPendingPermissions = useSessionStore(
(state) => state.setPendingPermissions (state) => state.setPendingPermissions
); );
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const clearDraftInput = useDraftStore((state) => state.clearDraftInput); const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const updateSessionClient = useSessionStore((state) => state.updateSessionClient); 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( const requestCanonicalCatchUp = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => { (agentId: string, cursor: { epoch: string; endSeq: number }) => {
void client void client
@@ -1356,15 +1259,6 @@ function SessionProviderInternal({
next.delete(agentId); next.delete(agentId);
return next; 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) => { const unsubAgentArchived = client.on("agent_archived", (message) => {
@@ -1420,7 +1314,6 @@ function SessionProviderInternal({
setAgents, setAgents,
setAgentLastActivity, setAgentLastActivity,
setPendingPermissions, setPendingPermissions,
setFileExplorer,
setHasHydratedAgents, setHasHydratedAgents,
clearDraftInput, clearDraftInput,
notifyAgentAttention, 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 // Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1,4 +1,4 @@
import { useCallback } from "react"; import { useCallback, useMemo } from "react";
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store"; import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
function createExplorerState(): AgentFileExplorerState { function createExplorerState(): AgentFileExplorerState {
@@ -24,34 +24,81 @@ function pushHistory(history: string[], path: string): string[] {
return [...normalizedHistory, path]; 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 client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer); const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const normalizedWorkspaceRoot = useMemo(
() => normalizeWorkspaceValue(workspaceRoot),
[workspaceRoot]
);
const workspaceStateKey = useMemo(
() =>
buildWorkspaceExplorerStateKey({
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
}),
[workspaceId, normalizedWorkspaceRoot]
);
const updateExplorerState = useCallback( const updateExplorerState = useCallback(
(agentId: string, updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => { (updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => {
if (!workspaceStateKey) {
return;
}
setFileExplorer(serverId, (prev) => { setFileExplorer(serverId, (prev) => {
const next = new Map(prev); const next = new Map(prev);
const current = next.get(agentId) ?? createExplorerState(); const current = next.get(workspaceStateKey) ?? createExplorerState();
next.set(agentId, updater(current)); next.set(workspaceStateKey, updater(current));
return next; return next;
}); });
}, },
[serverId, setFileExplorer] [serverId, setFileExplorer, workspaceStateKey]
); );
const requestDirectoryListing = useCallback( const requestDirectoryListing = useCallback(
async ( async (
agentId: string,
path: string, path: string,
options?: { recordHistory?: boolean; setCurrentPath?: boolean } options?: { recordHistory?: boolean; setCurrentPath?: boolean }
) => { ) => {
if (!workspaceStateKey) {
return;
}
const normalizedPath = path && path.length > 0 ? path : "."; const normalizedPath = path && path.length > 0 ? path : ".";
const shouldSetCurrentPath = options?.setCurrentPath ?? true; const shouldSetCurrentPath = options?.setCurrentPath ?? true;
const shouldRecordHistory = const shouldRecordHistory =
options?.recordHistory ?? (shouldSetCurrentPath ? true : false); options?.recordHistory ?? (shouldSetCurrentPath ? true : false);
updateExplorerState(agentId, (state) => ({ updateExplorerState((state) => ({
...state, ...state,
isLoading: true, isLoading: true,
lastError: null, 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) { if (!client) {
updateExplorerState(agentId, (state) => ({ updateExplorerState((state) => ({
...state, ...state,
isLoading: false, isLoading: false,
lastError: "Host is not connected", lastError: "Host is not connected",
@@ -78,8 +135,12 @@ export function useFileExplorerActions(serverId: string) {
} }
try { try {
const payload = await client.exploreFileSystem(agentId, normalizedPath, "list"); const payload = await client.exploreFileSystem(
updateExplorerState(agentId, (state) => { normalizedWorkspaceRoot,
normalizedPath,
"list"
);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = { const nextState: AgentFileExplorerState = {
...state, ...state,
isLoading: false, isLoading: false,
@@ -98,7 +159,7 @@ export function useFileExplorerActions(serverId: string) {
return nextState; return nextState;
}); });
} catch (error) { } catch (error) {
updateExplorerState(agentId, (state) => ({ updateExplorerState((state) => ({
...state, ...state,
isLoading: false, isLoading: false,
lastError: error instanceof Error ? error.message : "Failed to list directory", 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( const requestFilePreview = useCallback(
async (agentId: string, path: string) => { async (path: string) => {
if (!workspaceStateKey) {
return;
}
const normalizedPath = path && path.length > 0 ? path : "."; const normalizedPath = path && path.length > 0 ? path : ".";
updateExplorerState(agentId, (state) => ({ updateExplorerState((state) => ({
...state, ...state,
isLoading: true, isLoading: true,
lastError: null, lastError: null,
pendingRequest: { path: normalizedPath, mode: "file" }, pendingRequest: { path: normalizedPath, mode: "file" },
})); }));
if (!normalizedWorkspaceRoot) {
updateExplorerState((state) => ({
...state,
isLoading: false,
lastError: "Workspace is unavailable",
pendingRequest: null,
}));
return;
}
if (!client) { if (!client) {
updateExplorerState(agentId, (state) => ({ updateExplorerState((state) => ({
...state, ...state,
isLoading: false, isLoading: false,
lastError: "Host is not connected", lastError: "Host is not connected",
@@ -130,8 +204,12 @@ export function useFileExplorerActions(serverId: string) {
} }
try { try {
const payload = await client.exploreFileSystem(agentId, normalizedPath, "file"); const payload = await client.exploreFileSystem(
updateExplorerState(agentId, (state) => { normalizedWorkspaceRoot,
normalizedPath,
"file"
);
updateExplorerState((state) => {
const nextState: AgentFileExplorerState = { const nextState: AgentFileExplorerState = {
...state, ...state,
isLoading: false, isLoading: false,
@@ -151,33 +229,36 @@ export function useFileExplorerActions(serverId: string) {
return nextState; return nextState;
}); });
} catch { } catch {
updateExplorerState(agentId, (state) => ({ updateExplorerState((state) => ({
...state, ...state,
isLoading: false, isLoading: false,
pendingRequest: null, pendingRequest: null,
})); }));
} }
}, },
[client, updateExplorerState] [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey]
); );
const requestFileDownloadToken = useCallback( const requestFileDownloadToken = useCallback(
async (agentId: string, path: string) => { async (path: string) => {
if (!normalizedWorkspaceRoot) {
throw new Error("Workspace is unavailable");
}
if (!client) { if (!client) {
throw new Error("Host is not connected"); throw new Error("Host is not connected");
} }
const payload = await client.requestDownloadToken(agentId, path); const payload = await client.requestDownloadToken(normalizedWorkspaceRoot, path);
if (payload.error) { if (payload.error) {
throw new Error(payload.error); throw new Error(payload.error);
} }
return payload; return payload;
}, },
[client] [client, normalizedWorkspaceRoot]
); );
const selectExplorerEntry = useCallback( const selectExplorerEntry = useCallback(
(agentId: string, path: string | null) => { (path: string | null) => {
updateExplorerState(agentId, (state) => ({ updateExplorerState((state) => ({
...state, ...state,
selectedEntryPath: path, selectedEntryPath: path,
})); }));
@@ -186,6 +267,7 @@ export function useFileExplorerActions(serverId: string) {
); );
return { return {
workspaceStateKey,
requestDirectoryListing, requestDirectoryListing,
requestFilePreview, requestFilePreview,
requestFileDownloadToken, requestFileDownloadToken,
@@ -47,7 +47,11 @@ export function useKeyboardShortcuts({
toggleFileExplorer?: () => void; toggleFileExplorer?: () => void;
}) { }) {
const router = useRouter(); 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); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
useEffect(() => { useEffect(() => {
@@ -285,6 +289,7 @@ export function useKeyboardShortcuts({
pathname, pathname,
resetModifiers, resetModifiers,
router, router,
routerPathname,
selectedAgentId, selectedAgentId,
toggleAgentList, toggleAgentList,
toggleFileExplorer, toggleFileExplorer,
@@ -1,189 +1,43 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from 'vitest'
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import { applyStoredOrdering } from './use-sidebar-agents-list'
import {
applySidebarUserOrdering,
type SidebarAgentListEntry,
} from "./use-sidebar-agents-list";
function toKey(entry: SidebarAgentListEntry): string { interface OrderedItem {
return `${entry.agent.serverId}:${entry.agent.id}`; key: string
} }
function createEntry(input: { function item(key: string): OrderedItem {
id: string; return { key }
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,
},
},
};
} }
describe("applySidebarUserOrdering", () => { describe('applyStoredOrdering', () => {
it("keeps unknown entries on the chronological baseline", () => { it('keeps unknown items on the baseline while applying stored order', () => {
const entries = [ const result = applyStoredOrdering({
createEntry({ id: "a", createdAt: "2026-03-01T10:00:00.000Z" }), items: [item('new'), item('a'), item('b')],
createEntry({ id: "b", createdAt: "2026-02-28T10:00:00.000Z" }), storedOrder: ['b', 'a'],
createEntry({ id: "x", createdAt: "2026-02-01T10:00:00.000Z" }), getKey: (entry) => entry.key,
]; })
const result = applySidebarUserOrdering({
entries,
order: ["server:b", "server:a"],
});
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", () => { it('ignores stale and duplicate stored keys', () => {
const entries = [ const result = applyStoredOrdering({
createEntry({ id: "new", createdAt: "2026-03-02T10:00:00.000Z" }), items: [item('x'), item('y')],
createEntry({ id: "a", createdAt: "2026-03-01T10:00:00.000Z" }), storedOrder: ['missing', 'y', 'y', 'x'],
createEntry({ id: "b", createdAt: "2026-02-28T10:00:00.000Z" }), getKey: (entry) => entry.key,
]; })
const result = applySidebarUserOrdering({
entries,
order: ["server:b", "server:a"],
});
expect(result.entries.map(toKey)).toEqual([ expect(result.map((entry) => entry.key)).toEqual(['y', 'x'])
"server:new", })
"server:b",
"server:a",
]);
});
it("keeps persisted placement stable across status changes", () => { it('returns baseline when there is no persisted order', () => {
const baseEntries = [ const baseline = [item('first'), item('second')]
createEntry({ const result = applyStoredOrdering({
id: "a", items: baseline,
createdAt: "2026-03-01T10:00:00.000Z", storedOrder: [],
status: "running", getKey: (entry) => entry.key,
}), })
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"];
const initial = applySidebarUserOrdering({ entries: baseEntries, order }); expect(result).toBe(baseline)
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);
});
});
+563 -299
View File
@@ -1,378 +1,642 @@
import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useCallback, useMemo, useSyncExternalStore } from 'react'
import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useQueries } from '@tanstack/react-query'
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore } from '@/stores/session-store'
import { import { getHostRuntimeStore } from '@/runtime/host-runtime'
getHostRuntimeStore, import { resolveProjectPlacement } from '@/utils/project-placement'
} from "@/runtime/host-runtime"; import { deriveSidebarStateBucket, type SidebarStateBucket } from '@/utils/sidebar-agent-state'
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import { import { checkoutStatusQueryKey } from '@/hooks/use-checkout-status-query'
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";
const SIDEBAR_DONE_FILL_TARGET = 50; const EMPTY_ORDER: string[] = []
const EMPTY_ORDER: string[] = []; const EMPTY_PROJECTS: SidebarProjectEntry[] = []
export interface SidebarProjectFilterOption { interface PaseoWorktreeEntry {
projectKey: string; worktreePath: string
projectName: string; createdAt: string
activeCount: number; branchName?: string | null
totalCount: number; head?: string | null
serverId: string;
workingDir: string;
} }
export interface SidebarAgentListEntry { export interface SidebarWorkspaceEntry {
agent: AggregatedAgent & { createdAt: Date }; workspaceKey: string
project: ProjectPlacementPayload; 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 { export interface SidebarAgentsListResult {
entries: SidebarAgentListEntry[]; projects: SidebarProjectEntry[]
projectFilterOptions: SidebarProjectFilterOption[]; isLoading: boolean
hasMoreEntries: boolean; isInitialLoad: boolean
isLoading: boolean; isRevalidating: boolean
isInitialLoad: boolean; refreshAll: () => void
isRevalidating: boolean;
refreshAll: () => void;
} }
function compareByCreatedAtDesc( interface MutableWorkspaceEntry {
left: SidebarAgentListEntry, workspaceKey: string
right: SidebarAgentListEntry 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<string>
workspacesByKey: Map<string, MutableWorkspaceEntry>
}
interface WorktreeRequest {
requestKey: string
serverId: string
projectKey: string
repoRoot: string
}
const SIDEBAR_BUCKET_PRIORITY: Record<SidebarStateBucket, number> = {
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 { ): number {
const createdDelta = right.agent.createdAt.getTime() - left.agent.createdAt.getTime(); if (left.isMainCheckout !== right.isMainCheckout) {
if (createdDelta !== 0) { return left.isMainCheckout ? -1 : 1
return createdDelta;
} }
const leftTitle = (left.agent.title?.trim() || "New agent").toLocaleLowerCase(); if (left.createdAt && right.createdAt) {
const rightTitle = (right.agent.title?.trim() || "New agent").toLocaleLowerCase(); const dateDelta = right.createdAt.getTime() - left.createdAt.getTime()
const titleDelta = leftTitle.localeCompare(rightTitle, undefined, { if (dateDelta !== 0) {
numeric: true, return dateDelta
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<string, SidebarAgentListEntry>();
for (const entry of sortedByCreatedAt) {
entryByKey.set(toSidebarAgentKey(entry), entry);
}
const prunedOrder: string[] = [];
const seenOrderKeys = new Set<string>();
for (const key of input.order) {
if (!entryByKey.has(key) || seenOrderKeys.has(key)) {
continue;
} }
seenOrderKeys.add(key); } else if (left.createdAt || right.createdAt) {
prunedOrder.push(key); 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<T>(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<string, T>()
for (const item of input.items) {
itemByKey.set(input.getKey(item), item)
}
const prunedOrder: string[] = []
const seen = new Set<string>()
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) { if (prunedOrder.length === 0) {
sorted.push(...sortedByCreatedAt); return input.items
} 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);
}
} }
const doneEntries = sorted.filter( const orderedSet = new Set(prunedOrder)
(entry) => const ordered: T[] = []
!isSidebarActiveAgent({ let orderedIndex = 0
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 visible: SidebarAgentListEntry[] = []; for (const item of input.items) {
let hiddenDoneCount = 0; const key = input.getKey(item)
for (const entry of sorted) { if (!orderedSet.has(key)) {
const key = toSidebarAgentKey(entry); ordered.push(item)
if ( continue
isSidebarActiveAgent({
status: entry.agent.status,
pendingPermissionCount: entry.agent.pendingPermissionCount,
requiresAttention: entry.agent.requiresAttention,
attentionReason: entry.agent.attentionReason,
})
) {
visible.push(entry);
continue;
} }
if (visibleDoneKeys.has(key)) {
visible.push(entry); const targetKey = prunedOrder[orderedIndex] ?? key
continue; orderedIndex += 1
} ordered.push(itemByKey.get(targetKey) ?? item)
hiddenDoneCount += 1;
} }
return { return ordered
entries: visible,
hasMore: hiddenDoneCount > 0,
};
} }
function toAggregatedAgent(params: { function ensureWorkspace(
source: Agent; project: MutableProjectEntry,
serverId: string; input: {
serverLabel: string; serverId: string
lastActivityAt: Date; cwd: string
}): AggregatedAgent & { createdAt: Date } { branchName: string | null
const source = params.source; 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 { return {
id: source.id, ...project,
serverId: params.serverId, repoRoots: new Set(project.repoRoots),
serverLabel: params.serverLabel, workspacesByKey: new Map(
title: source.title ?? null, Array.from(project.workspacesByKey.entries()).map(([key, workspace]) => [
status: source.status, key,
createdAt: source.createdAt, { ...workspace },
lastActivityAt: params.lastActivityAt, ])
cwd: source.cwd, ),
provider: source.provider, }
pendingPermissionCount: source.pendingPermissions.length, }
requiresAttention: source.requiresAttention,
attentionReason: source.attentionReason, function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
attentionTimestamp: source.attentionTimestamp ?? null, return `${serverId.trim()}::${projectKey.trim()}`
archivedAt: source.archivedAt ?? null,
labels: source.labels,
};
} }
export function useSidebarAgentsList(options?: { export function useSidebarAgentsList(options?: {
serverId?: string | null; serverId?: string | null
selectedProjectFilterKey?: string | null; enabled?: boolean
}): SidebarAgentsListResult { }): SidebarAgentsListResult {
const { daemons } = useDaemonRegistry(); const runtime = getHostRuntimeStore()
const runtime = getHostRuntimeStore();
const daemonLabelSignature = useMemo(
() =>
daemons
.map((daemon) => `${daemon.serverId}:${daemon.label ?? ""}`)
.join("|"),
[daemons]
);
const serverId = useMemo(() => { const serverId = useMemo(() => {
const value = options?.serverId; const value = options?.serverId
return typeof value === "string" && value.trim().length > 0 return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
? value.trim() }, [options?.serverId])
: null; const enabled = options?.enabled ?? true
}, [options?.serverId]);
const serverLabel = useMemo(() => {
if (!serverId) {
return "";
}
return daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
}, [daemonLabelSignature, serverId]);
const selectedProjectFilterKey = useMemo(() => { const persistedProjectOrder = useSidebarOrderStore((state) =>
const value = options?.selectedProjectFilterKey; serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER
if (typeof value !== "string") { )
return null; const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) =>
} serverId ? state.workspaceOrderByServerAndProject : {}
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 isActive = Boolean(serverId); const isActive = Boolean(serverId)
const liveAgents = useSessionStore((state) => const liveAgents = useSessionStore((state) =>
isActive && serverId ? state.sessions[serverId]?.agents ?? null : null isActive && serverId ? (state.sessions[serverId]?.agents ?? null) : null
); )
const agentLastActivity = useSessionStore((state) =>
isActive ? state.agentLastActivity : null
);
const runtimeStatusSignature = useSyncExternalStore( const runtimeStatusSignature = useSyncExternalStore(
(onStoreChange) => (onStoreChange) =>
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {}, isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
() => { () => {
if (!isActive || !serverId) { if (!isActive || !serverId) {
return "idle:idle"; return 'idle:idle'
} }
const snapshot = runtime.getSnapshot(serverId); const snapshot = runtime.getSnapshot(serverId)
const connectionStatus = snapshot?.connectionStatus ?? "idle"; const connectionStatus = snapshot?.connectionStatus ?? 'idle'
const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle"; const directoryStatus = snapshot?.agentDirectoryStatus ?? 'idle'
return `${connectionStatus}:${directoryStatus}`; return `${connectionStatus}:${directoryStatus}`
}, },
() => { () => {
if (!isActive || !serverId) { if (!isActive || !serverId) {
return "idle:idle"; return 'idle:idle'
} }
const snapshot = runtime.getSnapshot(serverId); const snapshot = runtime.getSnapshot(serverId)
const connectionStatus = snapshot?.connectionStatus ?? "idle"; const connectionStatus = snapshot?.connectionStatus ?? 'idle'
const directoryStatus = snapshot?.agentDirectoryStatus ?? "idle"; const directoryStatus = snapshot?.agentDirectoryStatus ?? 'idle'
return `${connectionStatus}:${directoryStatus}`; return `${connectionStatus}:${directoryStatus}`
} }
); )
const [connectionStatus = "idle", directoryStatus = "idle"] = const [connectionStatus = 'idle', directoryStatus = 'idle'] = runtimeStatusSignature.split(':', 2)
runtimeStatusSignature.split(":", 2);
const { const base = useMemo(() => {
entries,
projectFilterOptions,
hasAnyData,
hasMoreEntries,
} = useMemo(() => {
if (!isActive || !serverId || !liveAgents) { if (!isActive || !serverId || !liveAgents) {
return { return {
entries: [] as SidebarAgentListEntry[], projectsByKey: new Map<string, MutableProjectEntry>(),
projectFilterOptions: [] as SidebarProjectFilterOption[], worktreeRequests: [] as WorktreeRequest[],
hasAnyData: false, hasAnyData: false,
hasMoreEntries: false, }
};
} }
const seenAgentIds = new Set<string>(); const projectsByKey = new Map<string, MutableProjectEntry>()
const byProject = new Map<string, SidebarProjectFilterOption>();
const mergedEntries: SidebarAgentListEntry[] = [];
const pushEntry = (entry: SidebarAgentListEntry): void => { for (const sourceAgent of liveAgents.values()) {
if (entry.agent.archivedAt) { if (sourceAgent.archivedAt || sourceAgent.labels.ui !== 'true') {
return; 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 placement = resolveProjectPlacement({
const isActive = isSidebarActiveAgent({ projectPlacement: sourceAgent.projectPlacement ?? null,
status: entry.agent.status, cwd: sourceAgent.cwd,
pendingPermissionCount: entry.agent.pendingPermissionCount, })
requiresAttention: entry.agent.requiresAttention, const projectKey = normalizePath(placement.projectKey)
attentionReason: entry.agent.attentionReason, if (!projectKey) {
}); continue
if (existing) { }
existing.totalCount += 1;
if (isActive) { const checkoutCwd = normalizePath(placement.checkout.cwd || sourceAgent.cwd)
existing.activeCount += 1; 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<string>(),
workspacesByKey: new Map<string, MutableWorkspaceEntry>(),
} 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, { if (!project.iconWorkingDir) {
projectKey: entry.project.projectKey, project.iconWorkingDir = workspace.cwd
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;
} }
const project = resolveProjectPlacement({
projectPlacement: live.projectPlacement ?? null, projectsByKey.set(projectKey, project)
cwd: live.cwd,
});
const effectiveLastActivity =
agentLastActivity?.get(live.id) ?? live.lastActivityAt;
const agent = toAggregatedAgent({
source: live,
serverId,
serverLabel,
lastActivityAt: effectiveLastActivity,
});
pushEntry({ agent, project });
} }
const filteredEntries = selectedProjectFilterKey const worktreeRequests: WorktreeRequest[] = []
? mergedEntries.filter( for (const project of projectsByKey.values()) {
(entry) => entry.project.projectKey === selectedProjectFilterKey for (const repoRoot of project.repoRoots) {
) if (!repoRoot) {
: mergedEntries; continue
const ordered = applySidebarUserOrdering({ }
entries: filteredEntries, const requestKey = `${serverId}:${project.projectKey}:${repoRoot}`
order: persistedOrder, worktreeRequests.push({
}); requestKey,
const options = Array.from(byProject.values()).sort((left, right) => { serverId,
if (left.activeCount !== right.activeCount) { projectKey: project.projectKey,
return right.activeCount - left.activeCount; repoRoot,
})
} }
return left.projectName.localeCompare(right.projectName); }
});
worktreeRequests.sort((left, right) =>
left.requestKey.localeCompare(right.requestKey, undefined, {
numeric: true,
sensitivity: 'base',
})
)
return { return {
entries: ordered.entries, projectsByKey,
projectFilterOptions: options, worktreeRequests,
hasAnyData: mergedEntries.length > 0, hasAnyData: projectsByKey.size > 0,
hasMoreEntries: ordered.hasMore, }
}; }, [isActive, liveAgents, serverId])
const worktreeQueries = useQueries({
queries: base.worktreeRequests.map((request) => ({
queryKey: ['sidebarPaseoWorktreeList', request.serverId, request.repoRoot],
queryFn: async (): Promise<PaseoWorktreeEntry[]> => {
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<string, MutableProjectEntry>()
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, isActive,
liveAgents, persistedProjectOrder,
persistedOrder, persistedWorkspaceOrderByScope,
selectedProjectFilterKey, repoRootStatusQueries,
serverId, serverId,
serverLabel, worktreeQueries,
]); ])
const refreshAll = useCallback(() => { const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== "online") { if (!isActive || !serverId || connectionStatus !== 'online') {
return; return
} }
void runtime.refreshAgentDirectory({ serverId, page: { limit: 50 } }).catch(() => undefined); void runtime.refreshAgentDirectory({ serverId, page: { limit: 50 } }).catch(() => undefined)
}, [connectionStatus, isActive, runtime, serverId]); }, [connectionStatus, isActive, runtime, serverId])
const isDirectoryLoading = const isDirectoryLoading =
isActive && isActive &&
Boolean(serverId) && Boolean(serverId) &&
(directoryStatus === "initial_loading" || directoryStatus === "revalidating"); (directoryStatus === 'initial_loading' || directoryStatus === 'revalidating')
const isInitialLoad = isDirectoryLoading && !hasAnyData; const isInitialLoad = isDirectoryLoading && !base.hasAnyData
const isRevalidating = isDirectoryLoading && hasAnyData; const isRevalidating = isDirectoryLoading && base.hasAnyData
return { return {
entries, projects,
projectFilterOptions,
hasMoreEntries,
isLoading: isDirectoryLoading, isLoading: isDirectoryLoading,
isInitialLoad, isInitialLoad,
isRevalidating, isRevalidating,
refreshAll, refreshAll,
}; }
} }
@@ -9,7 +9,25 @@ describe("keyboard-shortcut-routing", () => {
it("allows the shortcut on selected-agent routes", () => { it("allows the shortcut on selected-agent routes", () => {
const canToggle = canToggleFileExplorerShortcut({ const canToggle = canToggleFileExplorerShortcut({
selectedAgentId: "server-1:agent-1", 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, toggleFileExplorer,
}); });
@@ -18,7 +36,7 @@ describe("keyboard-shortcut-routing", () => {
it("allows the shortcut on draft routes", () => { it("allows the shortcut on draft routes", () => {
const canToggle = canToggleFileExplorerShortcut({ const canToggle = canToggleFileExplorerShortcut({
pathname: "/h/server-1/agent", pathname: "/h/server-1/new",
toggleFileExplorer, toggleFileExplorer,
}); });
@@ -27,7 +45,7 @@ describe("keyboard-shortcut-routing", () => {
it("blocks the shortcut when no toggle handler exists", () => { it("blocks the shortcut when no toggle handler exists", () => {
const canToggle = canToggleFileExplorerShortcut({ const canToggle = canToggleFileExplorerShortcut({
pathname: "/h/server-1/agent", pathname: "/h/server-1/new",
}); });
expect(canToggle).toBe(false); expect(canToggle).toBe(false);
@@ -1,6 +1,8 @@
import { import {
parseHostAgentDraftRouteFromPathname, parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
const DRAFT_AGENT_ID = "__new_agent__"; const DRAFT_AGENT_ID = "__new_agent__";
@@ -12,7 +14,9 @@ export function resolveSelectedOrRouteAgentKey(input: {
if (input.selectedAgentId) { if (input.selectedAgentId) {
return input.selectedAgentId; return input.selectedAgentId;
} }
const route = parseHostAgentRouteFromPathname(input.pathname); const route =
parseHostWorkspaceAgentRouteFromPathname(input.pathname) ??
parseHostAgentRouteFromPathname(input.pathname);
if (!route) { if (!route) {
const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname); const draftRoute = parseHostAgentDraftRouteFromPathname(input.pathname);
if (!draftRoute) { if (!draftRoute) {
@@ -31,10 +35,17 @@ export function canToggleFileExplorerShortcut(input: {
if (!input.toggleFileExplorer) { if (!input.toggleFileExplorer) {
return false; return false;
} }
return ( if (parseHostWorkspaceRouteFromPathname(input.pathname)) {
resolveSelectedOrRouteAgentKey({ return true;
selectedAgentId: input.selectedAgentId, }
pathname: input.pathname,
}) !== null if (parseHostAgentRouteFromPathname(input.pathname)) {
); return true;
}
if (parseHostAgentDraftRouteFromPathname(input.pathname)) {
return true;
}
return false;
} }
@@ -105,9 +105,15 @@ function logAgentExplorer(event: string, details: Record<string, unknown>): void
export function AgentReadyScreen({ export function AgentReadyScreen({
serverId, serverId,
agentId, agentId,
showHeader = true,
showExplorerSidebar = true,
wrapWithExplorerSidebarProvider = true,
}: { }: {
serverId: string; serverId: string;
agentId: string; agentId: string;
showHeader?: boolean;
showExplorerSidebar?: boolean;
wrapWithExplorerSidebarProvider?: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const resolvedAgentId = agentId?.trim() || undefined; const resolvedAgentId = agentId?.trim() || undefined;
@@ -193,17 +199,23 @@ export function AgentReadyScreen({
); );
} }
return ( const content = (
<ExplorerSidebarAnimationProvider> <AgentScreenContent
<AgentScreenContent serverId={resolvedServerId}
serverId={resolvedServerId} agentId={resolvedAgentId}
agentId={resolvedAgentId} client={runtimeClient}
client={runtimeClient} isConnected={runtimeIsConnected}
isConnected={runtimeIsConnected} connectionStatus={connectionStatus}
connectionStatus={connectionStatus} showHeader={showHeader}
/> showExplorerSidebar={showExplorerSidebar}
</ExplorerSidebarAnimationProvider> />
); );
if (!wrapWithExplorerSidebarProvider) {
return content;
}
return <ExplorerSidebarAnimationProvider>{content}</ExplorerSidebarAnimationProvider>;
} }
type AgentScreenContentProps = { type AgentScreenContentProps = {
@@ -212,6 +224,8 @@ type AgentScreenContentProps = {
client: DaemonClient; client: DaemonClient;
isConnected: boolean; isConnected: boolean;
connectionStatus: HostRuntimeConnectionStatus; connectionStatus: HostRuntimeConnectionStatus;
showHeader: boolean;
showExplorerSidebar: boolean;
}; };
function toErrorMessage(error: unknown): string { function toErrorMessage(error: unknown): string {
@@ -231,6 +245,8 @@ function AgentScreenContent({
client, client,
isConnected, isConnected,
connectionStatus, connectionStatus,
showHeader,
showExplorerSidebar,
}: AgentScreenContentProps) { }: AgentScreenContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
@@ -990,7 +1006,7 @@ function AgentScreenContent({
if (viewState.tag === "not_found") { if (viewState.tag === "not_found") {
return ( return (
<View style={styles.container} testID="agent-not-found"> <View style={styles.container} testID="agent-not-found">
<MenuHeader title="Agent" /> {showHeader ? <MenuHeader title="Agent" /> : null}
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text> <Text style={styles.errorText}>Agent not found</Text>
</View> </View>
@@ -1001,7 +1017,7 @@ function AgentScreenContent({
if (viewState.tag === "error") { if (viewState.tag === "error") {
return ( return (
<View style={styles.container} testID="agent-load-error"> <View style={styles.container} testID="agent-load-error">
<MenuHeader title="Agent" /> {showHeader ? <MenuHeader title="Agent" /> : null}
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Text style={styles.errorText}>Failed to load agent</Text> <Text style={styles.errorText}>Failed to load agent</Text>
<Text style={styles.statusText}>{viewState.message}</Text> <Text style={styles.statusText}>{viewState.message}</Text>
@@ -1013,7 +1029,7 @@ function AgentScreenContent({
if (viewState.tag === "boot" || !effectiveAgent) { if (viewState.tag === "boot" || !effectiveAgent) {
return ( return (
<View style={styles.container} testID="agent-loading"> <View style={styles.container} testID="agent-loading">
<MenuHeader title="Agent" /> {showHeader ? <MenuHeader title="Agent" /> : null}
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} /> <ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View> </View>
@@ -1029,80 +1045,81 @@ function AgentScreenContent({
> >
<View style={styles.container}> <View style={styles.container}>
{/* Header */} {/* Header */}
<MenuHeader {showHeader ? (
title={effectiveAgent.title || "Agent"} <MenuHeader
rightContent={ title={effectiveAgent.title || "Agent"}
<View style={styles.headerRightContent}> rightContent={
<HeaderToggleButton <View style={styles.headerRightContent}>
onPress={handleToggleExplorer} <HeaderToggleButton
tooltipLabel="Toggle explorer" onPress={handleToggleExplorer}
tooltipKeys={["mod", "E"]} tooltipLabel="Toggle explorer"
tooltipSide="left" tooltipKeys={["mod", "E"]}
style={styles.menuButton} tooltipSide="left"
accessible style={styles.menuButton}
accessibilityRole="button" accessible
accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"} accessibilityRole="button"
accessibilityState={{ expanded: isExplorerOpen }} accessibilityLabel={isExplorerOpen ? "Close explorer" : "Open explorer"}
> accessibilityState={{ expanded: isExplorerOpen }}
{isMobile ? ( >
checkout?.isGit ? ( {isMobile ? (
<GitBranch checkout?.isGit ? (
size={theme.iconSize.lg} <GitBranch
color={ size={theme.iconSize.lg}
isExplorerOpen color={
? theme.colors.foreground isExplorerOpen
: theme.colors.foregroundMuted ? theme.colors.foreground
} : theme.colors.foregroundMuted
/> }
/>
) : (
<Folder
size={theme.iconSize.lg}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)
) : ( ) : (
<Folder <PanelRight
size={theme.iconSize.lg} size={theme.iconSize.md}
color={ color={
isExplorerOpen isExplorerOpen
? theme.colors.foreground ? theme.colors.foreground
: theme.colors.foregroundMuted : theme.colors.foregroundMuted
} }
/> />
) )}
) : ( </HeaderToggleButton>
<PanelRight <DropdownMenu
size={theme.iconSize.md} onOpenChange={(open) => {
color={ if (open && agent?.cwd) {
isExplorerOpen checkoutStatusQuery.refresh().catch(() => {});
? theme.colors.foreground
: theme.colors.foregroundMuted
} }
/> }}
)} >
</HeaderToggleButton> <DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
<DropdownMenu <MoreVertical
onOpenChange={(open) => { size={isMobile ? theme.iconSize.lg : theme.iconSize.md}
if (open && agent?.cwd) { color={theme.colors.foregroundMuted}
checkoutStatusQuery.refresh().catch(() => {}); />
} </DropdownMenuTrigger>
}} <DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
> <View style={styles.menuMetaContainer} testID="agent-details-sheet">
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}> <Pressable
<MoreVertical testID="agent-details-agent-id"
size={isMobile ? theme.iconSize.lg : theme.iconSize.md} style={({ hovered, pressed }) => [
color={theme.colors.foregroundMuted} styles.menuMetaRow,
/> (hovered || pressed) && styles.menuMetaRowActive,
</DropdownMenuTrigger> ]}
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content"> onPress={() => {
<View style={styles.menuMetaContainer} testID="agent-details-sheet"> void handleCopyMeta("Directory", effectiveAgent.cwd);
<Pressable }}
testID="agent-details-agent-id" >
style={({ hovered, pressed }) => [ <Text style={styles.menuMetaLabel} numberOfLines={1}>
styles.menuMetaRow, Directory
(hovered || pressed) && styles.menuMetaRowActive, </Text>
]}
onPress={() => {
void handleCopyMeta("Directory", effectiveAgent.cwd);
}}
>
<Text style={styles.menuMetaLabel} numberOfLines={1}>
Directory
</Text>
<Text <Text
testID="agent-details-agent-id-value" testID="agent-details-agent-id-value"
style={styles.menuMetaValue} style={styles.menuMetaValue}
@@ -1224,11 +1241,12 @@ function AgentScreenContent({
> >
{isInitializing ? "Refreshing..." : "Refresh"} {isInitializing ? "Refreshing..." : "Refresh"}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</View> </View>
} }
/> />
) : null}
{/* Content Area with Keyboard Animation */} {/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
@@ -1272,14 +1290,14 @@ function AgentScreenContent({
</FileDropZone> </FileDropZone>
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && ( {showExplorerSidebar && !isMobile && isExplorerOpen && resolvedAgentId ? (
<ExplorerSidebar <ExplorerSidebar
serverId={serverId} serverId={serverId}
agentId={resolvedAgentId} workspaceId={effectiveAgent.cwd}
cwd={effectiveAgent.cwd} workspaceRoot={effectiveAgent.cwd}
isGit={isGitCheckout} isGit={isGitCheckout}
/> />
)} ) : null}
{isArchivingCurrentAgent ? ( {isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay"> <View style={styles.archivingOverlay} testID="agent-archiving-overlay">
@@ -1304,14 +1322,14 @@ function AgentScreenContent({
)} )}
{/* Mobile Explorer Sidebar Overlay */} {/* Mobile Explorer Sidebar Overlay */}
{isMobile && resolvedAgentId && ( {showExplorerSidebar && isMobile && resolvedAgentId ? (
<ExplorerSidebar <ExplorerSidebar
serverId={serverId} serverId={serverId}
agentId={resolvedAgentId} workspaceId={effectiveAgent.cwd}
cwd={effectiveAgent.cwd} workspaceRoot={effectiveAgent.cwd}
isGit={isGitCheckout} isGit={isGitCheckout}
/> />
)} ) : null}
</> </>
); );
} }
@@ -1342,8 +1342,8 @@ function DraftAgentScreenContent({
{!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? ( {!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? (
<ExplorerSidebar <ExplorerSidebar
serverId={explorerServerId} serverId={explorerServerId}
agentId={DRAFT_AGENT_ID} workspaceId={draftExplorerCheckout.cwd}
cwd={draftExplorerCheckout.cwd} workspaceRoot={draftExplorerCheckout.cwd}
isGit={explorerIsGit} isGit={explorerIsGit}
/> />
) : null} ) : null}
@@ -1365,8 +1365,8 @@ function DraftAgentScreenContent({
{isMobile && explorerServerId && draftExplorerCheckout ? ( {isMobile && explorerServerId && draftExplorerCheckout ? (
<ExplorerSidebar <ExplorerSidebar
serverId={explorerServerId} serverId={explorerServerId}
agentId={DRAFT_AGENT_ID} workspaceId={draftExplorerCheckout.cwd}
cwd={draftExplorerCheckout.cwd} workspaceRoot={draftExplorerCheckout.cwd}
isGit={explorerIsGit} isGit={explorerIsGit}
/> />
) : null} ) : 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<AgentMatch[]>(() => {
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 = (
<View style={styles.centerState}>
<Text style={styles.title}>Missing agent</Text>
<Text style={styles.subtitle}>
This link is missing an agent id. Go back to the Agents screen to pick one.
</Text>
<Pressable style={styles.primaryButton} onPress={handleGoDraft}>
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
</View>
);
} else if (!hasSessions || isRedirecting) {
body = (
<View style={styles.centerState}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={styles.subtitle}>
{isRedirecting ? "Opening your agent..." : "Looking for your agent..."}
</Text>
</View>
);
} else if (matches.length === 0) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Agent not found</Text>
<Text style={styles.subtitle}>
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.
</Text>
<Pressable style={styles.primaryButton} onPress={handleGoDraft}>
<Text style={styles.primaryButtonText}>New agent</Text>
</Pressable>
</View>
);
} else if (matches.length > 1) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Pick a host</Text>
<Text style={styles.subtitle}>
Multiple hosts have an agent with this id. Choose the one you intended to open.
</Text>
<View style={styles.matchList}>
{matches.map((match) => (
<Pressable
key={`${match.serverId}:${match.agent.id}`}
style={styles.matchButton}
onPress={() => handleSelectMatch(match)}
>
<Text style={styles.matchLabel}>{match.serverLabel}</Text>
<Text style={styles.matchMeta} numberOfLines={1}>
{match.agent.cwd}
</Text>
</Pressable>
))}
</View>
</View>
);
}
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={handleGoDraft} />
<View style={styles.content}>{body}</View>
</View>
);
}
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,
},
}));
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -18,7 +18,7 @@ interface DownloadProgress {
export interface Download { export interface Download {
id: string; id: string;
serverId: string; serverId: string;
agentId: string; scopeId: string;
fileName: string; fileName: string;
status: "downloading" | "complete" | "error"; status: "downloading" | "complete" | "error";
message?: string; message?: string;
@@ -32,14 +32,11 @@ interface DownloadState {
startDownload: (params: { startDownload: (params: {
serverId: string; serverId: string;
agentId: string; scopeId: string;
fileName: string; fileName: string;
path: string; path: string;
daemonProfile: HostProfile | undefined; daemonProfile: HostProfile | undefined;
requestFileDownloadToken: ( requestFileDownloadToken: (path: string) => Promise<{
agentId: string,
path: string
) => Promise<{
token: string | null; token: string | null;
fileName: string | null; fileName: string | null;
mimeType: string | null; mimeType: string | null;
@@ -64,7 +61,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
startDownload: async ({ startDownload: async ({
serverId, serverId,
agentId, scopeId,
fileName, fileName,
path, path,
daemonProfile, daemonProfile,
@@ -74,7 +71,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
const download: Download = { const download: Download = {
id, id,
serverId, serverId,
agentId, scopeId,
fileName, fileName,
status: "downloading", status: "downloading",
startedAt: Date.now(), startedAt: Date.now(),
@@ -86,7 +83,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
})); }));
try { try {
const tokenResponse = await requestFileDownloadToken(agentId, path); const tokenResponse = await requestFileDownloadToken(path);
if (tokenResponse.error || !tokenResponse.token) { if (tokenResponse.error || !tokenResponse.token) {
throw new Error(tokenResponse.error ?? "Failed to request download token."); throw new Error(tokenResponse.error ?? "Failed to request download token.");
} }
@@ -1,7 +1,7 @@
export type ExplorerTab = "changes" | "files" | "terminals"; export type ExplorerTab = "changes" | "files";
export function isExplorerTab(value: unknown): value is ExplorerTab { 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 { export function buildExplorerCheckoutKey(serverId: string, cwd: string): string | null {
+3 -3
View File
@@ -44,7 +44,7 @@ describe("panel-store explorer tab resolution", () => {
).toBe("files"); ).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)!; const key = buildExplorerCheckoutKey(serverId, cwd)!;
expect( expect(
resolveExplorerTabForCheckout({ resolveExplorerTabForCheckout({
@@ -52,10 +52,10 @@ describe("panel-store explorer tab resolution", () => {
cwd, cwd,
isGit: true, isGit: true,
explorerTabByCheckout: { explorerTabByCheckout: {
[key]: "terminals", [key]: "terminals" as any,
}, },
}) })
).toBe("terminals"); ).toBe("changes");
}); });
it("coerces stored changes to files for non-git checkouts", () => { it("coerces stored changes to files for non-git checkouts", () => {
+5 -1
View File
@@ -336,7 +336,7 @@ export const usePanelStore = create<PanelState>()(
}), }),
{ {
name: "panel-state", name: "panel-state",
version: 4, version: 5,
storage: createJSONStorage(() => AsyncStorage), storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState, version) => { migrate: (persistedState, version) => {
const state = persistedState as Partial<PanelState> & Record<string, unknown>; const state = persistedState as Partial<PanelState> & Record<string, unknown>;
@@ -369,6 +369,10 @@ export const usePanelStore = create<PanelState>()(
} }
} }
if (!isExplorerTab(state.explorerTab)) {
state.explorerTab = "changes";
}
if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) { if (version < 4 || typeof state.explorerTabByCheckout !== "object" || !state.explorerTabByCheckout) {
state.explorerTabByCheckout = {}; state.explorerTabByCheckout = {};
} else { } else {
+57 -63
View File
@@ -1,99 +1,93 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from '@react-native-async-storage/async-storage'
import { create } from "zustand"; import { create } from 'zustand'
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from 'zustand/middleware'
interface SidebarOrderStoreState { interface SidebarOrderStoreState {
orderByServerId: Record<string, string[]>; projectOrderByServerId: Record<string, string[]>
getOrder: (serverId: string) => string[]; workspaceOrderByServerAndProject: Record<string, string[]>
setOrder: (serverId: string, keys: string[]) => void; getProjectOrder: (serverId: string) => string[]
insertAtTop: (serverId: string, key: string) => void; setProjectOrder: (serverId: string, keys: string[]) => void
remove: (serverId: string, key: string) => void; getWorkspaceOrder: (serverId: string, projectKey: string) => string[]
setWorkspaceOrder: (serverId: string, projectKey: string, keys: string[]) => void
} }
function normalizeKeys(keys: string[]): string[] { function normalizeKeys(keys: string[]): string[] {
const seen = new Set<string>(); const seen = new Set<string>()
const normalized: string[] = []; const normalized: string[] = []
for (const rawKey of keys) { for (const rawKey of keys) {
const key = rawKey.trim(); const key = rawKey.trim()
if (!key || seen.has(key)) { if (!key || seen.has(key)) {
continue; continue
} }
seen.add(key); seen.add(key)
normalized.push(key); normalized.push(key)
} }
return normalized; return normalized
}
function buildWorkspaceScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`
} }
export const useSidebarOrderStore = create<SidebarOrderStoreState>()( export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
orderByServerId: {}, projectOrderByServerId: {},
getOrder: (serverId) => { workspaceOrderByServerAndProject: {},
const key = serverId.trim(); getProjectOrder: (serverId) => {
const key = serverId.trim()
if (!key) { if (!key) {
return []; return []
} }
return get().orderByServerId[key] ?? []; return get().projectOrderByServerId[key] ?? []
}, },
setOrder: (serverId, keys) => { setProjectOrder: (serverId, keys) => {
const key = serverId.trim(); const key = serverId.trim()
if (!key) { if (!key) {
return; return
} }
const normalized = normalizeKeys(keys); const normalized = normalizeKeys(keys)
set((state) => ({ set((state) => ({
orderByServerId: { projectOrderByServerId: {
...state.orderByServerId, ...state.projectOrderByServerId,
[key]: normalized, [key]: normalized,
}, },
})); }))
}, },
insertAtTop: (serverId, rawKey) => { getWorkspaceOrder: (serverId, projectKey) => {
const serverKey = serverId.trim(); const serverKey = serverId.trim()
const entryKey = rawKey.trim(); const projectScope = projectKey.trim()
if (!serverKey || !entryKey) { if (!serverKey || !projectScope) {
return; return []
} }
set((state) => { const scopeKey = buildWorkspaceScopeKey(serverKey, projectScope)
const current = state.orderByServerId[serverKey] ?? []; return get().workspaceOrderByServerAndProject[scopeKey] ?? []
const next = [entryKey, ...current.filter((key) => key !== entryKey)];
return {
orderByServerId: {
...state.orderByServerId,
[serverKey]: next,
},
};
});
}, },
remove: (serverId, rawKey) => { setWorkspaceOrder: (serverId, projectKey, keys) => {
const serverKey = serverId.trim(); const serverKey = serverId.trim()
const entryKey = rawKey.trim(); const projectScope = projectKey.trim()
if (!serverKey || !entryKey) { if (!serverKey || !projectScope) {
return; return
} }
set((state) => { const scopeKey = buildWorkspaceScopeKey(serverKey, projectScope)
const current = state.orderByServerId[serverKey] ?? []; const normalized = normalizeKeys(keys)
const next = current.filter((key) => key !== entryKey); set((state) => ({
if (next.length === current.length) { workspaceOrderByServerAndProject: {
return state; ...state.workspaceOrderByServerAndProject,
} [scopeKey]: normalized,
return { },
orderByServerId: { }))
...state.orderByServerId,
[serverKey]: next,
},
};
});
}, },
}), }),
{ {
name: "sidebar-agent-order", name: 'sidebar-project-workspace-order',
storage: createJSONStorage(() => AsyncStorage), storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({ partialize: (state) => ({
orderByServerId: state.orderByServerId, projectOrderByServerId: state.projectOrderByServerId,
workspaceOrderByServerAndProject: state.workspaceOrderByServerAndProject,
}), }),
} }
) )
); )
@@ -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<string, WorkspaceTabTarget>;
setLastFocusedTab: (input: {
serverId: string;
workspaceId: string;
tab: WorkspaceTabTarget;
}) => void;
getLastFocusedTab: (input: {
serverId: string;
workspaceId: string;
}) => WorkspaceTabTarget | null;
};
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
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<string, WorkspaceTabTarget>;
}
| undefined;
const raw = state?.lastFocusedTabByWorkspace ?? {};
const next: Record<string, WorkspaceTabTarget> = {};
for (const key in raw) {
const value = raw[key];
const normalized = normalizeWorkspaceTab(value);
if (normalized) {
next[key] = normalized;
}
}
return {
...state,
lastFocusedTabByWorkspace: next,
};
},
}
)
);
+49 -2
View File
@@ -2,18 +2,22 @@ import { describe, expect, it } from "vitest";
import { import {
parseHostAgentDraftRouteFromPathname, parseHostAgentDraftRouteFromPathname,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseHostDraftRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
parseHostWorkspaceTerminalRouteFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "./host-routes"; } from "./host-routes";
describe("parseHostAgentDraftRouteFromPathname", () => { describe("parseHostAgentDraftRouteFromPathname", () => {
it("parses draft route server id", () => { it("parses draft route server id", () => {
expect(parseHostAgentDraftRouteFromPathname("/h/local/agent")).toEqual({ expect(parseHostAgentDraftRouteFromPathname("/h/local/new")).toEqual({
serverId: "local", serverId: "local",
}); });
}); });
it("parses encoded server id", () => { it("parses encoded server id", () => {
expect( expect(
parseHostAgentDraftRouteFromPathname("/h/team%20host/agent") parseHostAgentDraftRouteFromPathname("/h/team%20host/new")
).toEqual({ ).toEqual({
serverId: "team host", 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", () => { describe("parseHostAgentRouteFromPathname", () => {
it("continues parsing detail routes", () => { it("continues parsing detail routes", () => {
expect(parseHostAgentRouteFromPathname("/h/local/agent/abc123")).toEqual({ 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",
});
});
});
+147 -4
View File
@@ -57,7 +57,7 @@ export function parseHostAgentRouteFromPathname(
export function parseHostAgentDraftRouteFromPathname( export function parseHostAgentDraftRouteFromPathname(
pathname: string pathname: string
): { serverId: string } | null { ): { serverId: string } | null {
const match = pathname.match(/^\/h\/([^/]+)\/agent\/?$/); const match = pathname.match(/^\/h\/([^/]+)\/(?:agent|new)\/?$/);
if (!match) { if (!match) {
return null; return null;
} }
@@ -73,17 +73,151 @@ export function parseHostAgentDraftRouteFromPathname(
} }
export function buildHostAgentDraftRoute(serverId: string): string { 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); const normalized = trimNonEmpty(serverId);
if (!normalized) { if (!normalized) {
return "/"; 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( export function buildHostAgentDetailRoute(
serverId: string, serverId: string,
agentId: string agentId: string,
workspaceId?: string
): string { ): string {
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (normalizedWorkspaceId) {
return buildHostWorkspaceAgentRoute(
serverId,
normalizedWorkspaceId,
agentId
);
}
const normalizedServerId = trimNonEmpty(serverId); const normalizedServerId = trimNonEmpty(serverId);
const normalizedAgentId = trimNonEmpty(agentId); const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedServerId || !normalizedAgentId) { if (!normalizedServerId || !normalizedAgentId) {
@@ -127,5 +261,14 @@ export function mapPathnameToServer(
if (suffix.startsWith("agents")) { if (suffix.startsWith("agents")) {
return `${base}/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;
} }
@@ -10,13 +10,13 @@ import {
describe("buildNewAgentRoute", () => { describe("buildNewAgentRoute", () => {
it("falls back to host-scoped draft route when no working directory is provided", () => { 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", undefined)).toBe("/h/srv-1/new");
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/agent"); expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/new");
}); });
it("encodes the working directory query parameter", () => { it("encodes the working directory query parameter", () => {
expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe( 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", () => { it("prefers the agent in the current route", () => {
expect( expect(
resolveSelectedAgentForNewAgent({ resolveSelectedAgentForNewAgent({
pathname: "/h/srv-1/agent/agent-2", pathname: "/h/srv-1/workspace/%2Frepo/agent/agent-2",
selectedAgentId: "srv-9:agent-9", selectedAgentId: "srv-9:agent-9",
}) })
).toEqual({ ).toEqual({
@@ -2,6 +2,7 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import { import {
buildHostAgentDraftRoute, buildHostAgentDraftRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseHostWorkspaceAgentRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
export function parseAgentKey( export function parseAgentKey(
@@ -26,6 +27,10 @@ export function resolveSelectedAgentForNewAgent(input: {
pathname: string; pathname: string;
selectedAgentId?: string; selectedAgentId?: string;
}): { serverId: string; agentId: string } | null { }): { serverId: string; agentId: string } | null {
const workspaceRoute = parseHostWorkspaceAgentRouteFromPathname(input.pathname);
if (workspaceRoute) {
return { serverId: workspaceRoute.serverId, agentId: workspaceRoute.agentId };
}
return ( return (
parseHostAgentRouteFromPathname(input.pathname) ?? parseHostAgentRouteFromPathname(input.pathname) ??
parseAgentKey(input.selectedAgentId) parseAgentKey(input.selectedAgentId)
@@ -38,7 +38,7 @@ describe("buildNotificationRoute", () => {
}); });
it("falls back to host-scoped draft route when only serverId is present", () => { 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", () => { it("falls back to root when no server id is present", () => {
@@ -1,6 +1,6 @@
import { import {
buildHostDraftRoute,
buildHostAgentDetailRoute, buildHostAgentDetailRoute,
buildHostAgentDraftRoute,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
type NotificationData = Record<string, unknown> | null | undefined; type NotificationData = Record<string, unknown> | null | undefined;
@@ -33,7 +33,7 @@ export function buildNotificationRoute(data: NotificationData): string {
return buildHostAgentDetailRoute(serverId, agentId); return buildHostAgentDetailRoute(serverId, agentId);
} }
if (serverId) { if (serverId) {
return buildHostAgentDraftRoute(serverId); return buildHostDraftRoute(serverId);
} }
return "/"; return "/";
} }
+11 -5
View File
@@ -204,16 +204,20 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
return `remote:${cleanedHost}/${cleanedPath}` 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) const remoteKey = deriveRemoteProjectKey(options.remoteUrl)
if (remoteKey) { if (remoteKey) {
return remoteKey return remoteKey
} }
const worktreeMarker = '.paseo/worktrees/' const mainRepoRoot = options.mainRepoRoot?.trim()
const idx = options.cwd.indexOf(worktreeMarker) if (options.isPaseoOwnedWorktree && mainRepoRoot) {
if (idx !== -1) { return mainRepoRoot
return options.cwd.slice(0, idx).replace(/\/$/, '')
} }
return options.cwd return options.cwd
@@ -1225,6 +1229,8 @@ export class Session {
const projectKey = deriveProjectGroupingKey({ const projectKey = deriveProjectGroupingKey({
cwd, cwd,
remoteUrl: checkout.remoteUrl, remoteUrl: checkout.remoteUrl,
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
mainRepoRoot: checkout.mainRepoRoot,
}) })
return { return {
projectKey, projectKey,