feat: projects → workspaces → tabs
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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
@@ -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,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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 "/";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user