refactor: replace store-based keyboard action requests with direct dispatcher and simplify agent attention clearing

This commit is contained in:
Mohamed Boudra
2026-03-06 14:49:06 +07:00
parent 885b575d14
commit 5b270444d5
25 changed files with 617 additions and 1355 deletions
@@ -1,185 +0,0 @@
# Iteration 2 — Projects → Workspaces → Tabs (UX Polish + Behavior Fixes)
This plan is a follow-up iteration on the already-merged “Projects → Workspaces → Tabs” redesign.
Scope is **client-only UX/behavior fixes** requested after manual testing.
---
## Goals (What changes in this iteration)
### 1) Sidebar drag behavior (Projects + Workspaces)
- **Project drag moves the whole section**
- Dragging a **project heading** reorders the **entire project section** (heading + its workspaces), even when expanded.
- **Workspace drag is scoped**
- Dragging a **workspace** only reorders **within its project**.
- A workspace must **never end up visually under another project** after drop.
- **No “invalid” intermediate layouts**
- If a drag results in a transient invalid arrangement (e.g., a workspace visually dropped between projects), the list must **snap back** to the canonical `Project → Workspaces` structure immediately after drop.
### 2) Sidebar visuals + navigation polish
- **Workspace rows match project row styling**
- Remove the “weird border” on workspaces.
- Workspace rows should follow the same “ghost”/hover/pressed style language as project headings.
- **Reduce workspace indentation/padding**
- Workspaces are currently too indented; reduce indent, especially on mobile.
- **Remove noisy “No agents yet” label**
- Empty workspaces are valid; do not display a “No agents yet” label anywhere in the sidebar tree.
- **Workspace click closes sidebar (mobile)**
- When tapping a workspace from the left sidebar on mobile, the sidebar closes before navigation.
### 3) Workspace header + tab bar fixes
- **Workspace header title is the branch name**
- For git workspaces, the header must show the **current branch name**, including the base branch (e.g., `main`).
- For non-git workspaces, fall back to workspace name.
- **Agents and terminals are first-class tab types**
- Remove “special layout” treatment where agents and terminals have creation controls on different rows.
- Provide a **single unified “New tab” control** that can create either:
- **New agent** (draft agent flow, pre-scoped to the workspace)
- **New terminal** (create terminal tab)
- **Agent tabs show provider icons**
- Terminal tabs: terminal icon.
- Agent tabs: provider icon (at minimum: **Claude/cloud** + **Codex** using existing app icon assets/components).
- **Remember focused tab per workspace (critical)**
- If a user focuses an agent/terminal tab in Workspace A, switches to Workspace B, and returns to Workspace A, the app must restore the previously focused tab.
- This must work even if the agent/terminal lists are still loading (do not overwrite the stored selection with a fallback while data is pending).
---
## User Flows (Agreed / Required)
### Sidebar
1. User opens left sidebar.
2. User expands a project to reveal workspaces.
3. User drags a project header:
- Project moves relative to other projects.
- All its workspaces remain grouped with it.
4. User drags a workspace:
- Only reorders within its project.
- Cannot be dropped into another project section.
5. User taps a workspace on mobile:
- Sidebar closes.
- Workspace screen opens with branch name in header.
### Workspace screen
1. User opens a workspace.
2. User can switch between agent and terminal tabs.
3. User uses the unified “New tab” control to create either:
- a new agent (draft flow), or
- a new terminal
4. User switches to a different workspace and returns:
- The previously focused tab is restored.
---
## Acceptance Criteria (Strict)
### Sidebar drag behavior
- Dragging a project heading reorders projects, and after drop **the project heading is never separated from its own workspaces**.
- Dragging a workspace can only reorder within its project; after drop **a workspace is never displayed under another project**.
- After any drag, the sidebar tree snaps back to valid `Project → Workspaces` grouping (no “interleaved” layout remains).
### Sidebar visuals + navigation
- Workspace rows have **no border** and visually match the “ghost” style language of project headings.
- Workspace indentation/padding is reduced (especially mobile).
- “No agents yet” is **not rendered anywhere** in the sidebar tree.
- Clicking a workspace on mobile closes the left sidebar before navigation.
### Workspace header + tabs
- Git workspace header shows **branch name**, including base branch (e.g., `main`).
- There is **one unified “New tab” control** (not separate rows for agent vs terminal creation).
- Agent tabs show provider icons (Claude/Codex at minimum).
- Focused tab is restored when returning to a workspace (agent or terminal), and stored selection is not overwritten while lists are loading.
---
## Implementation Plan (Code)
### A) Sidebar drag scoping + snap-back
Target file(s):
- `packages/app/src/components/sidebar-agent-list.tsx`
Work:
- Ensure drag end always triggers a re-render that restores the canonical `Project → Workspaces` structure (even if persisted order does not change).
- Ensure reorder persistence remains:
- projects: device store project order
- workspaces: device store per-project workspace order
### B) Sidebar styling + labels
Target file(s):
- `packages/app/src/components/sidebar-agent-list.tsx`
Work:
- Remove the “No agents yet” createdAt label for empty workspaces (render empty/omitted createdAt instead).
- Align workspace row visuals with project row:
- remove border
- adjust padding
- reduce indentation (responsive: tighter on mobile)
### C) Workspace screen: header title + unified “New tab”
Target file(s):
- `packages/app/src/screens/workspace/workspace-screen.tsx`
Work:
- Header title uses branch name for git checkouts (including base branch).
- Replace separate “Create agent” vs “Create terminal” placement with one unified “New tab” control.
### D) Workspace screen: provider icons + tab persistence
Target file(s):
- `packages/app/src/screens/workspace/workspace-screen.tsx`
- (potentially) `packages/app/src/stores/workspace-tabs-store.ts`
Work:
- Agent tab icons render provider icon (Claude/Codex).
- Fix tab restore so requested/stored tabs are respected while data loads:
- do not redirect away from a requested tab just because lists haven’t populated yet
- do not overwrite stored tab selection with fallback while queries are pending
---
## Verification Plan
### Automated
```bash
npm run typecheck
npm run test --workspace=@getpaseo/app
```
### Manual (agent-browser)
Using Metro web at `http://localhost:8081`:
1. Sidebar drag:
- Expand a project, drag its heading above/below another project → confirm its workspaces stay grouped.
- Drag a workspace “across” another project → confirm after drop it remains under original project.
2. Sidebar UI:
- Confirm workspace rows have no border and are less indented.
- Confirm no “No agents yet” labels.
- On mobile breakpoint, tap a workspace → sidebar closes.
3. Workspace tabs:
- Confirm header shows branch name (including `main`).
- Confirm agent tabs show provider icons (Claude/Codex).
- Confirm there is one unified “New tab” control for both agent + terminal.
- Focus a terminal tab, switch to another workspace, switch back → terminal tab restored.
---
## Non-goals
- No panel splitting implementation (just keep architecture compatible).
- No server protocol changes (daemon stays untouched on `:6767`).
- No additional persistence beyond what’s required to satisfy “remember focused tab”.
@@ -1,124 +0,0 @@
# Iteration 3 Plan — Workspace Header + Tabs Restore
## Goal
Bring the `Workspace` screen layout back in line with the pre-tabs “Agent screen” structure (left sidebar + center content + right explorer sidebar), while keeping the new **workspace-scoped tabs** model (Agent + Terminal tabs) and the **project → workspaces** sidebar.
This iteration focuses on restoring missing/incorrect UX affordances:
- **New tab** dropdown must open and be visible.
- **Right explorer sidebar** must be togglable from the workspace header (with the correct icon behavior).
- **Chat overflow (kebab) menu** must exist again when an Agent tab is active.
- **Terminal tabs** must be closable from the workspace tab strip (X), since terminals are first-class tabs.
- **Gestures** for opening/closing sidebars must remain unchanged.
## Non‑Goals (for this iteration)
- Panel splitting / VS Code-style split views (explicitly deferred).
- Changing the existing left sidebar open/close gestures or right explorer swipe gestures (must remain behaviorally identical).
- Introducing a DB / rehydration changes (agent hydration is already handled at startup).
## Acceptance Criteria
### A. New Tab menu
1. Clicking **New tab** in the workspace header opens a visible dropdown/menu on desktop web.
2. The menu contains exactly:
- **Agent tab** → navigates to the draft agent flow pre-scoped to the current workspace.
- **Terminal tab** → creates a new terminal for the current workspace and focuses it.
3. The menu placement is correct (not off-screen), following established in-app patterns.
### B. Workspace header parity (restored controls)
1. Workspace header shows **workspace name = branch name** (as already implemented).
2. Header contains:
- Left sidebar toggle (existing)
- Right explorer sidebar toggle (restored)
- Chat overflow **kebab menu** (restored) when an Agent tab is active
3. Right explorer toggle icon matches legacy behavior:
- On mobile: **Git** workspace → Git icon; non-git → Folder icon
- Visual “open” state is reflected (muted vs active color).
### C. Tabs UX parity
1. Desktop workspace tab strip:
- Shows provider icon for Agent tabs (Claude/Codex) and terminal icon for Terminal tabs.
- Terminal tabs include an **X** close affordance.
2. Closing a terminal tab:
- Calls the existing kill terminal API
- Removes the terminal from the workspace tab strip
- Leaves the workspace in a valid focused state (falls back to another available tab).
### D. Right explorer sidebar structure & gestures (no regressions)
1. Explorer sidebar is the same **right panel** conceptually as before:
- Not embedded “inside” the tab content surface
- Toggled by header button and swipe gesture on mobile
2. Gestures remain unchanged:
- Left sidebar: swipe right to open (mobile), swipe left/backdrop to close
- Right sidebar: swipe left to open (mobile), swipe right/backdrop/X to close
### E. Verification gates
1. `npm run typecheck` passes.
2. `npm run test --workspace=@getpaseo/app` (vitest) passes.
3. Playwright E2E:
- Add/update a spec that proves New tab menu opens and is on-screen.
- Add/update a spec that toggles the explorer sidebar from the workspace header.
- Run `npm run test:e2e --workspace=@getpaseo/app` (uses isolated daemon/metro; must NOT hit `:6767`).
4. Manual verification with `agent-browser` (desktop + mobile viewport) confirms:
- New tab menu visible and clickable
- Explorer toggle present and works
- Kebab menu present for Agent tab
- Terminal close X works
## Implementation Outline
### 1) Workspace header composition
- Refactor `packages/app/src/screens/workspace/workspace-screen.tsx` header to match the legacy “agent header” affordances:
- Use the same header primitives used by `AgentReadyScreen` (`MenuHeader`, `HeaderToggleButton`, `DropdownMenu`).
- Restore explorer toggle button in the workspace header.
- Restore agent kebab menu (only when active tab is an Agent tab).
### 2) New tab dropdown placement fix
- Ensure the New tab dropdown uses a pattern that cannot render off-screen near the top of the viewport:
- Prefer the existing header-menu pattern (DropdownMenu), or
- If using Combobox, set an explicit `desktopPlacement` that renders below the trigger.
### 3) Terminal tab close affordance
- Add close (X) for terminal tabs in the workspace tab strip.
- Implement a minimal kill-terminal mutation in `WorkspaceScreen` (mirroring the existing `TerminalPane` mutation behavior) and ensure focus fallback.
### 4) E2E coverage
- Add `packages/app/e2e/workspace-tabs-header.spec.ts` (or similar) covering:
- New tab menu opening (visible on screen)
- Explorer toggle presence + open/close behavior
## User Flows (must work)
1. **Open workspace**
- Tap workspace in sidebar → sidebar closes → workspace screen opens
- Header shows branch name
2. **Open new terminal tab**
- Header → New tab → Terminal tab
- Terminal appears as a tab (with terminal icon)
- Terminal is focused
3. **Close terminal tab**
- Click X on terminal tab
- Terminal is killed and disappears from tab strip
- Focus returns to another tab
4. **Toggle explorer**
- Header right icon toggles right sidebar
- Mobile swipe gesture still opens the right sidebar
5. **Agent overflow menu**
- With an Agent tab active, kebab menu opens
- Menu content appears (parity with previous agent overflow)
@@ -1,120 +0,0 @@
# Iteration 4 — Workspace header/layout restore (tabs + explorer parity)
## Goal
Fix regressions introduced by the workspace-tabs migration by restoring the **legacy workspace/agent screen layout and gestures**, while keeping the new **workspace tab model** (agent + terminal tabs).
This iteration focuses on **header controls + panel layout parity**, and fixing the broken **New tab** menu on desktop.
## Context (what must remain true)
- **Left sidebar** stays as-is (projects/workspaces list) and keeps existing swipe/gesture behavior.
- **Right sidebar** stays as-is (Changes/Files explorer) and keeps existing swipe/gesture behavior.
- **Terminals and agents are first-class tabs**:
- Terminal tabs are not “special” or in a different layout region.
- New tab creation UI must treat agent + terminal options equally (same menu surface).
- Workspace header shows **workspace name (branch name when git, otherwise workspace name)**.
- No “legacy view” routing or duplicate UI kept around; everything uses the new workspace-tabs flow.
- Do **not** touch/restart the main daemon on `:6767`. Tests/dev servers must use isolated ports/homes.
## UX Requirements (desktop + mobile)
### Header structure (parity with legacy AgentReadyScreen)
Workspace screen header must include:
1) **Left sidebar toggle** (same component/gesture behavior as before).
2) **Title** = branch name if git & branch is known; otherwise workspace name.
3) **Right explorer toggle** (same behavior as before):
- Desktop icon: panel/right icon
- Mobile icon: `GitBranch` if git checkout, else `Folder`
- Correct `aria-expanded` state and open/closed coloring
4) **New tab** dropdown button that reliably opens on-screen (desktop + mobile).
5) **Overflow (kebab / 3-dot) menu for agent chat**:
- Visible only when the active tab is an **agent** (i.e. “chat context”).
- Menu items should mirror legacy intent (at minimum: open chat view with full header).
### Tabs
- Tabs remain horizontally scrollable (browser-style).
- Tabs must include an icon per tab type:
- Agent tabs show provider icon (Claude/Codex; fallback bot).
- Terminal tabs show terminal icon.
- Tabs must support closing terminal tabs via `X` (desktop):
- Confirm destructive close.
- Killing terminal updates the list and selects a valid remaining tab (or falls back to workspace root).
### Panel layout
- Keep the screen layout: **header → tabs row → content center → explorer right panel**.
- Do not nest the explorer UI inside tab content in a way that changes layout/gesture wiring.
### Gestures (must not regress)
- Left sidebar open/close gestures unchanged.
- Explorer (right sidebar) open/close gestures unchanged.
- Mobile “open explorer” pan gesture on content remains wired the same as before.
## Acceptance Criteria
### New tab dropdown
- Clicking the `New tab` header button opens a dropdown that is **visible on-screen** on desktop (not positioned off-screen).
- Dropdown contains **two options**:
- Agent tab → opens draft agent flow for that workspace.
- Terminal tab → creates a terminal and opens its tab.
### Workspace header parity
- Header shows both sidebar toggles (left menu + right explorer toggle).
- Right explorer toggle icon matches legacy behavior:
- Mobile: git vs folder icon
- Desktop: panel-right icon
- Explorer toggle opens/closes the explorer reliably and updates `aria-expanded`.
### Agent kebab menu
- When an agent tab is active, the kebab menu is visible and opens correctly.
- When a terminal tab is active, the kebab menu is not shown.
### Tab close
- Terminal tabs display an `X` close affordance on desktop (hover or active).
- Closing a terminal:
- Confirms close
- Calls kill terminal RPC
- Removes the tab from the list
- Focuses a valid remaining tab (or navigates to workspace root if none)
### Regression checks
- Explorer panel remains on the right and behaves the same as before (including gestures).
- Left sidebar gestures remain unchanged.
## Verification
### Automated
- `npm run typecheck` (must pass)
- `npm run test --workspace=@getpaseo/app` (must pass)
- Playwright E2E:
- Add/ensure a spec that asserts `New tab` dropdown opens and is within the viewport.
- Add/ensure a spec that toggles explorer via header button and asserts open/close.
### Manual (agent-browser)
Desktop (Metro web):
- Confirm header shows: left toggle, title, explorer toggle, `New tab` dropdown, kebab menu (agent only).
- Confirm `New tab` dropdown opens and is visible.
- Confirm terminal tab shows `X` close and closes correctly.
Mobile viewport (Metro web responsive):
- Confirm explorer toggle uses git/folder icon.
- Confirm swipes/gestures for sidebars still work.
## Non-goals (explicitly out of scope for this iteration)
- Panel splitting / drag-to-split implementation (architectural compatibility only).
- New workspace creation UX, workspace archiving rules beyond existing behavior.
- Agent hydration/indexing redesign (local SQL DB) beyond current in-memory hydration.
-448
View File
@@ -1,448 +0,0 @@
# 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.
-130
View File
@@ -1,130 +0,0 @@
# Workspace Source-Of-Truth Cleanup Plan
## Scope
Only this worktree:
`/Users/moboudra/.paseo/worktrees/1luy0po7/workspace-sot-hard-cut`
## Objectives
1. One server-owned source of truth for workspace identity.
2. Header and sidebar render the same workspace name field.
3. Remove client-side workspace construction from agent data.
4. Hard cut legacy paths (no dual-read compatibility layer).
## Non-Objectives
1. No UI-only taxonomy in canonical model (`subtitle`, `titleKind`).
2. No React derivation from branch/path basename for workspace naming.
3. No silent fallbacks masking missing state (`?? ""`, cwd fallback label).
## Phase 0: Audit Existing Worktree Changes
1. Inventory all modified files in this worktree.
2. For each hunk, classify as `keep`, `delete`, or `rewrite`.
3. Build a bad-pattern list and remove all of these:
- Workspace naming fallback in React.
- Canonical payload fields that are presentation-only.
- Any primary label logic equivalent to `Unknown branch`.
- Any sidebar workspace list constructed from live agent rows.
### Acceptance
1. Full inventory exists before further refactor work.
2. Every bad pattern has a mapped removal location.
## Phase 1: Canonical Model Simplification
Define a minimal server canonical workspace entity:
- `id`: normalized cwd (single identity field)
- `projectId`: stable grouping key
- `name`: canonical workspace name (server computed)
- `status`: dominant state bucket
- `activityAt`: nullable ISO timestamp
Do not include UI/presentation fields in canonical payload.
### Acceptance
1. No duplicate identity fields (`workspaceId` + `cwd`) in canonical contract.
2. No subtitle/title-kind fields in canonical contract.
## Phase 2: Server Aggregation and RPC
1. Build workspace aggregation in `session.ts` from authoritative server state.
2. Compute workspace `name` once in server policy code.
3. Provide:
- `fetch_workspaces_request/response` snapshot RPC
- `workspace_update` incremental upsert/remove stream
4. Keep update semantics aligned with existing directory subscription behavior.
### Acceptance
1. Non-git and git workspaces both resolve deterministic `name`.
2. No `Unknown branch` emitted by server.
3. Workspace update stream reflects agent lifecycle/state changes.
## Phase 3: Client Store Wiring
1. Add workspace map in session store keyed by canonical `id`.
2. Hydrate only from `fetch_workspaces`.
3. Apply only `workspace_update` events for incremental updates.
4. Keep workspace state channel separate from agent-list derivation.
### Acceptance
1. Workspace map is populated strictly from workspace RPCs.
2. No workspace assembly logic from agent lists in runtime/store.
## Phase 4: UI Hard Cut
1. Sidebar row label reads only `workspace.name`.
2. Header title reads only `workspace.name`.
3. Remove all local workspace naming helpers based on cwd/branch.
4. Missing descriptor after hydration must render explicit missing-state UI, not fallback text.
### Acceptance
1. Header and sidebar names are guaranteed identical source field.
2. No `?? ""`/cwd/branch fallback paths for workspace name.
## Phase 5: Legacy Deletion
1. Delete prior client sidebar workspace-building path(s).
2. Remove `Unknown branch` primary naming logic.
3. Remove any query/helper used only for old workspace naming.
4. Delete dead types/helpers from failed intermediate designs.
### Acceptance
1. No app code path builds workspace rows from agent lists.
2. Legacy naming/building code fully removed in same change set.
## Phase 6: Verification
1. Protocol/schema tests for workspace request/response/update.
2. Server aggregation tests:
- non-git workspace
- git branch workspace
- detached/headless workspace
- removal/update behavior
3. UI tests:
- selected workspace header name equals sidebar workspace name
- explicit missing-state rendering when descriptor absent post-hydration
4. Run `npm run typecheck`.
5. Manual validation in this worktree daemon+expo stack.
### Acceptance
1. Tests pass.
2. Typecheck passes.
3. Manual verification confirms name parity and status correctness.
## Execution Guardrails
1. Work only in `workspace-sot-hard-cut`.
2. No destructive git operations.
3. No fallback naming logic in React.
4. One naming policy and source of truth: server.
5. Hard cut means old code removed, not left dormant.
+120 -40
View File
@@ -1,9 +1,24 @@
import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
import {
createAgent,
createAgentInRepo,
ensureHostSelected,
gotoHome,
openSettings,
setWorkingDirectory,
} from './helpers/app';
import { createTempGitRepo } from './helpers/workspace';
import type { Page } from '@playwright/test';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import {
buildHostWorkspaceAgentRoute,
buildHostWorkspaceRoute,
} from '@/utils/host-routes';
import {
ensureWorkspaceAgentPaneVisible,
waitForWorkspaceTabsVisible,
} from './helpers/workspace-tabs';
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -110,6 +125,89 @@ async function addFakeMicrophone(page: Page) {
}, { base64Audio, mimeType });
}
async function expectComposerReady(page: Page) {
await expect(page.getByRole('textbox', { name: 'Message agent...' }).first()).toBeEditable();
}
async function expectDictationStarted(page: Page) {
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
}
async function expectDictationStopped(page: Page) {
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
}
test('dictation hotkey works on a workspace agent tab', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
await createAgentInRepo(page, {
directory: repo.path,
prompt: 'Respond with exactly: Hello',
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
expect(calls).toBe(1);
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation hotkey works on a workspace draft tab', async ({ page }) => {
await addFakeMicrophone(page);
const repo = await createTempGitRepo();
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error('E2E_SERVER_ID is not set.');
}
await createAgentInRepo(page, {
directory: repo.path,
prompt: 'Respond with exactly: Hello',
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await waitForWorkspaceTabsVisible(page);
await ensureWorkspaceAgentPaneVisible(page);
await page.getByTestId('workspace-new-agent-tab').first().click();
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expectDictationStarted(page);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
expect(calls).toBe(1);
await page.keyboard.press('Escape');
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
});
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
await addFakeMicrophone(page);
@@ -119,23 +217,18 @@ test('dictation hotkeys do not trigger on background screens', async ({ page })
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await openSettings(page);
await page.keyboard.press('Control+d');
await page.waitForTimeout(200);
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
const active = await page.evaluate(() => (window as any).__mic.active as number);
expect(calls).toBe(1);
expect(active).toBe(1);
await page.keyboard.press('Escape');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
expect(calls).toBe(0);
expect(active).toBe(0);
} finally {
await repo.cleanup();
}
@@ -150,23 +243,18 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
await ensureHostSelected(page);
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
await expectDictationStarted(page);
const initialCopyMessageCount = await page
.getByRole('button', { name: 'Copy message' })
.count();
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
await expectDictationStopped(page);
await expect
.poll(
@@ -189,13 +277,11 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) =
await setWorkingDirectory(page, repo.path);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent\//);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await expect(page).toHaveURL(/\/workspace\//);
await expectComposerReady(page);
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
await expectDictationStarted(page);
await page.evaluate(() => {
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
@@ -205,9 +291,7 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) =
});
await page.keyboard.press('Escape');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(0);
await expectDictationStopped(page);
} finally {
await repo.cleanup();
}
@@ -223,35 +307,31 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
await ensureHostSelected(page);
await createAgent(page, 'Respond with exactly: Hello');
await expect(page).toHaveURL(/\/agent($|\/)/);
const match = page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
await expect(page).toHaveURL(/\/workspace\//);
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
if (!match) {
throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${page.url()}`);
throw new Error(`Expected workspace agent URL, got ${page.url()}`);
}
const serverId = decodeURIComponent(match[1]!);
const agentId = decodeURIComponent(match[2]!);
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
await expectComposerReady(page);
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
await page.keyboard.press('Control+d');
await expect
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
.toBe(1);
await expectDictationStarted(page);
await page.keyboard.press('Control+d');
const newAgentButton = page.getByTestId('sidebar-new-agent');
await expect(newAgentButton).toBeVisible();
await newAgentButton.click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
await page.waitForTimeout(10_000);
const agentEntry = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
await expect(agentEntry).toBeVisible();
await agentEntry.click();
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
await expect(page).toHaveURL(
new RegExp(`/h/${escapeRegex(serverId)}/agent/${escapeRegex(agentId)}(?:\\?|$)`)
new RegExp(`/h/${escapeRegex(serverId)}/workspace/[^?]+\\?open=agent(?:%3A|:)${escapeRegex(agentId)}(?:$|&)`)
);
await expect(page.getByRole('button', { name: 'Copy message' })).toHaveCount(initialCopyMessageCount);
+3 -2
View File
@@ -371,8 +371,9 @@ export const createAgent = async (page: Page, message: string) => {
await input.fill(message);
await input.press('Enter');
// Router updates can be same-document transitions; assert URL state instead of waiting for a commit event.
await expect(page).toHaveURL(/\/(workspace|agent)\//, { timeout: 30000 });
// The composer may remain on the draft screen briefly while the initial run starts,
// so assert the user-visible result instead of forcing one route shape here.
await expect(page).toHaveURL(/\/(workspace|agent|new-agent)(\/|$|\?)/, { timeout: 30000 });
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
timeout: 30000,
});
@@ -21,7 +21,6 @@ import {
import { Theme } from '@/styles/theme'
import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query'
import { encodeImages } from '@/utils/encode-images'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { focusWithRetries } from '@/utils/web-focus'
import { useVoiceOptional } from '@/contexts/voice-context'
import { useToast } from '@/contexts/toast-context'
@@ -38,6 +37,9 @@ import { resolveStatusControlMode } from '@/components/agent-input-area.status-c
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler'
import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher'
import { shouldClearAgentAttention } from '@/utils/agent-attention'
type QueuedMessage = {
id: string
@@ -89,10 +91,6 @@ export function AgentInputArea({
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isScreenFocused = useIsFocused()
const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest)
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
(s) => s.clearMessageInputActionRequest
)
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
const toast = useToast()
@@ -134,10 +132,13 @@ export function AgentInputArea({
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([])
const [isCancellingAgent, setIsCancellingAgent] = useState(false)
const [sendError, setSendError] = useState<string | null>(null)
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null)
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false)
const messageInputRef = useRef<MessageInputRef>(null)
const draftGenerationRef = useRef(0)
const hydratedGenerationRef = useRef(0)
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`
)
const autocomplete = useAgentAutocomplete({
userInput,
@@ -240,8 +241,18 @@ export function AgentInputArea({
messageId: clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
})
if (
shouldClearAgentAttention({
agentId,
isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
})
) {
client.clearAgentAttention(agentId)
}
}
}, [client, serverId, setAgentStreamTail, setAgentStreamHead])
}, [agent?.attentionReason, agent?.requiresAttention, client, isConnected, serverId, setAgentStreamTail, setAgentStreamHead])
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage
@@ -513,50 +524,58 @@ export function AgentInputArea({
userInput,
])
// Keyboard-dispatched message-input actions are routed through store requests.
useEffect(() => {
if (!isScreenFocused) return
if (!messageInputActionRequest) return
const currentKey = `${serverId}:${agentId}`
if (messageInputActionRequest.agentKey !== currentKey) {
return
const handleKeyboardAction = useCallback((action: KeyboardActionDefinition): boolean => {
if (!isScreenFocused) {
return false
}
if (lastHandledMessageInputActionRequestIdRef.current === messageInputActionRequest.id) {
return
}
lastHandledMessageInputActionRequestIdRef.current = messageInputActionRequest.id
switch (action.id) {
case 'message-input.focus':
if (Platform.OS !== 'web') {
messageInputRef.current?.focus()
return true
}
if (messageInputActionRequest.kind !== 'focus') {
messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind)
clearMessageInputActionRequest(messageInputActionRequest.id)
return
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null
const active = typeof document !== 'undefined' ? document.activeElement : null
return Boolean(el) && active === el
},
})
return true
case 'message-input.dictation-toggle':
messageInputRef.current?.runKeyboardAction('dictation-toggle')
return true
case 'message-input.dictation-cancel':
messageInputRef.current?.runKeyboardAction('dictation-cancel')
return true
case 'message-input.voice-toggle':
messageInputRef.current?.runKeyboardAction('voice-toggle')
return true
case 'message-input.voice-mute-toggle':
messageInputRef.current?.runKeyboardAction('voice-mute-toggle')
return true
default:
return false
}
}, [isScreenFocused])
if (Platform.OS !== 'web') {
messageInputRef.current?.focus()
clearMessageInputActionRequest(messageInputActionRequest.id)
return
}
return focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null
const active = typeof document !== 'undefined' ? document.activeElement : null
return Boolean(el) && active === el
},
onSuccess: () => clearMessageInputActionRequest(messageInputActionRequest.id),
onTimeout: () => clearMessageInputActionRequest(messageInputActionRequest.id),
})
}, [
agentId,
clearMessageInputActionRequest,
isScreenFocused,
messageInputActionRequest,
serverId,
])
useKeyboardActionHandler({
handlerId: keyboardHandlerIdRef.current,
actions: [
'message-input.focus',
'message-input.dictation-toggle',
'message-input.dictation-cancel',
'message-input.voice-toggle',
'message-input.voice-mute-toggle',
],
enabled: isScreenFocused,
priority: isMessageInputFocused ? 200 : 100,
isActive: () => isScreenFocused,
handle: handleKeyboardAction,
})
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: 'translate',
@@ -803,6 +822,7 @@ export function AgentInputArea({
onSelectionChange={(selection) => {
setCursorIndex(selection.start)
}}
onFocusChange={setIsMessageInputFocused}
/>
</View>
</View>
@@ -83,6 +83,7 @@ export interface MessageInputProps {
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean
/** Reports cursor selection updates from the underlying input. */
onSelectionChange?: (selection: { start: number; end: number }) => void
onFocusChange?: (focused: boolean) => void
}
export interface MessageInputRef {
@@ -151,6 +152,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
onSelectionChange: onSelectionChangeCallback,
onFocusChange,
},
ref
) {
@@ -234,6 +236,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
valueRef.current = value
}, [value])
useEffect(() => {
return () => {
onFocusChange?.(false)
}
}, [onFocusChange])
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => {
if (!IS_WEB || !autoFocus) return
@@ -707,9 +715,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
placeholderTextColor={theme.colors.mutedForeground}
onFocus={() => {
isInputFocusedRef.current = true
onFocusChange?.(true)
}}
onBlur={() => {
isInputFocusedRef.current = false
onFocusChange?.(false)
}}
style={[
styles.textInput,
@@ -1181,7 +1181,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
workspaceListContainer: {
marginLeft: theme.spacing[2],
marginLeft: 0,
},
emptyText: {
color: theme.colors.foregroundMuted,
@@ -1192,7 +1192,7 @@ const styles = StyleSheet.create((theme) => ({
projectRow: {
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
flexDirection: 'row',
@@ -1253,7 +1253,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: 'row',
alignItems: 'center',
@@ -1329,6 +1329,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
opacity: 0.76,
flex: 1,
minWidth: 0,
},
@@ -1,10 +1,45 @@
type TimelineDirection = "tail" | "before" | "after";
type InitRequestDirection = "tail" | "after";
type TimelineCursor = {
type BootstrapTailCursor = {
seq: number;
} | null;
type InitialTimelineCursor = {
epoch: string;
seq: number;
} | null;
export function deriveInitialTimelineRequest({
cursor,
hasAuthoritativeHistory,
initialTimelineLimit,
}: {
cursor: InitialTimelineCursor;
hasAuthoritativeHistory: boolean;
initialTimelineLimit: number;
}): {
direction: "tail" | "after";
cursor?: { epoch: string; seq: number };
limit: number;
projection: "canonical";
} {
if (!hasAuthoritativeHistory || !cursor) {
return {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
};
}
return {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.seq },
limit: 0,
projection: "canonical",
};
}
export function deriveBootstrapTailTimelinePolicy({
direction,
reset,
@@ -16,7 +51,7 @@ export function deriveBootstrapTailTimelinePolicy({
direction: TimelineDirection;
reset: boolean;
epoch: string;
endCursor: TimelineCursor;
endCursor: BootstrapTailCursor;
isInitializing: boolean;
hasActiveInitDeferred: boolean;
}): {
@@ -2,11 +2,28 @@ import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-initialization";
describe("useAgentInitialization timeline request policy", () => {
it("uses canonical tail bootstrap when history has not synced yet", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: false,
initialTimelineLimit: 200,
})
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical tail bootstrap when cursor is missing", () => {
expect(
__private__.buildInitialTimelineRequest({
cursor: undefined,
hasLocalTail: false,
__private__.deriveInitialTimelineRequest({
cursor: null,
hasAuthoritativeHistory: true,
initialTimelineLimit: 200,
})
).toEqual({
@@ -16,31 +33,14 @@ describe("useAgentInitialization timeline request policy", () => {
});
});
it("uses canonical tail bootstrap when cursor exists but local tail is empty", () => {
it("uses canonical catch-up after the current cursor once history is synced", () => {
expect(
__private__.buildInitialTimelineRequest({
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
endSeq: 42,
seq: 42,
},
hasLocalTail: false,
initialTimelineLimit: 200,
})
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor when local tail exists", () => {
expect(
__private__.buildInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
endSeq: 42,
},
hasLocalTail: true,
hasAuthoritativeHistory: true,
initialTimelineLimit: 200,
})
).toEqual({
@@ -53,9 +53,9 @@ describe("useAgentInitialization timeline request policy", () => {
it("supports unbounded tail bootstrap policy", () => {
expect(
__private__.buildInitialTimelineRequest({
cursor: undefined,
hasLocalTail: false,
__private__.deriveInitialTimelineRequest({
cursor: null,
hasAuthoritativeHistory: false,
initialTimelineLimit: 0,
})
).toEqual({
@@ -3,7 +3,6 @@ import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store";
import type {
DaemonClient,
FetchAgentTimelineOptions,
} from "@server/client/daemon-client";
import {
attachInitTimeout,
@@ -12,43 +11,12 @@ import {
getInitKey,
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
const INIT_TIMEOUT_MS = 5 * 60_000;
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0;
type TimelineCursorState = {
epoch: string;
endSeq: number;
};
type BuildInitialTimelineRequestInput = {
cursor: TimelineCursorState | undefined;
hasLocalTail: boolean;
initialTimelineLimit: number;
};
function buildInitialTimelineRequest(
input: BuildInitialTimelineRequestInput
): FetchAgentTimelineOptions {
const { cursor, hasLocalTail, initialTimelineLimit } = input;
if (!cursor || !hasLocalTail) {
return {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
};
}
return {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
// Catch up all missing canonical rows by default.
limit: 0,
projection: "canonical",
};
}
function resolveInitialTimelineLimit(): number {
return Platform.OS === "web"
? UNBOUNDED_TIMELINE_LIMIT
@@ -56,7 +24,7 @@ function resolveInitialTimelineLimit(): number {
}
export const __private__ = {
buildInitialTimelineRequest,
deriveInitialTimelineRequest,
resolveInitialTimelineLimit,
};
@@ -92,11 +60,14 @@ export function useAgentInitialization({
const session = useSessionStore.getState().sessions[serverId];
const cursor = session?.agentTimelineCursor.get(agentId);
const hasLocalTail = (session?.agentStreamTail.get(agentId)?.length ?? 0) > 0;
const initialTimelineLimit = resolveInitialTimelineLimit();
const timelineRequest = buildInitialTimelineRequest({
cursor,
hasLocalTail,
const hasAuthoritativeHistory =
(session?.agentHistorySyncGeneration.get(agentId) ?? -1) >= 0;
const timelineRequest = deriveInitialTimelineRequest({
cursor: cursor
? { epoch: cursor.epoch, seq: cursor.endSeq }
: null,
hasAuthoritativeHistory,
initialTimelineLimit,
});
const initRequestDirection =
+7 -32
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import {
clearCommandCenterFocusRestoreElement,
@@ -17,10 +18,6 @@ import {
import type { ShortcutKey } from "@/utils/format-shortcut";
import { focusWithRetries } from "@/utils/web-focus";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
function isMatch(agent: AggregatedAgent, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
@@ -46,14 +43,6 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
}
function parseAgentKeyFromPathname(pathname: string): string | null {
const match = parseHostAgentRouteFromPathname(pathname);
if (!match) {
return null;
}
return `${match.serverId}:${match.agentId}`;
}
type CommandCenterActionDefinition = {
id: string;
title: string;
@@ -117,9 +106,6 @@ export function useCommandCenter() {
const { agents } = useAggregatedAgents();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
const requestMessageInputAction = useKeyboardShortcutsStore(
(s) => s.requestMessageInputAction
);
const inputRef = useRef<TextInput>(null);
const didNavigateRef = useRef(false);
const prevOpenRef = useRef(open);
@@ -134,11 +120,6 @@ export function useCommandCenter() {
const fallbackServerId = agents[0]?.serverId ?? null;
const agentKeyFromPathname = useMemo(
() => parseAgentKeyFromPathname(pathname),
[pathname]
);
const newAgentRoute = useMemo<Href>(() => {
const serverIdFromPath =
parseServerIdFromPathname(pathname) ?? fallbackServerId;
@@ -191,10 +172,6 @@ export function useCommandCenter() {
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
const navigate = shouldReplace ? router.replace : router.push;
requestMessageInputAction({
agentKey: agentKey(agent),
kind: "focus",
});
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
@@ -205,7 +182,7 @@ export function useCommandCenter() {
) as Href;
navigate(route);
},
[pathname, requestMessageInputAction, setOpen]
[pathname, setOpen]
);
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
@@ -245,12 +222,10 @@ export function useCommandCenter() {
focus: () => el?.focus(),
isFocused,
onTimeout: () => {
if (agentKeyFromPathname) {
requestMessageInputAction({
agentKey: agentKeyFromPathname,
kind: "focus",
});
}
keyboardActionDispatcher.dispatch({
id: "message-input.focus",
scope: "message-input",
});
},
});
return cancel;
@@ -265,7 +240,7 @@ export function useCommandCenter() {
inputRef.current?.focus();
}, 0);
return () => clearTimeout(id);
}, [agentKeyFromPathname, open, requestMessageInputAction]);
}, [open]);
useEffect(() => {
if (!open) return;
@@ -0,0 +1,37 @@
import { useEffect } from "react";
import {
keyboardActionDispatcher,
type KeyboardActionDefinition,
type KeyboardActionHandler,
type KeyboardActionId,
} from "@/keyboard/keyboard-action-dispatcher";
type UseKeyboardActionHandlerInput = {
handlerId: string;
actions: readonly KeyboardActionId[];
enabled: boolean;
priority: number;
isActive?: () => boolean;
handle: (action: KeyboardActionDefinition) => boolean;
};
export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) {
useEffect(() => {
return keyboardActionDispatcher.registerHandler({
handlerId: input.handlerId,
actions: input.actions,
enabled: input.enabled,
priority: input.priority,
isActive: input.isActive,
handle: input.handle,
});
}, [
input.actions,
input.enabled,
input.handle,
input.handlerId,
input.isActive,
input.priority,
]);
}
@@ -16,10 +16,8 @@ import {
type MessageInputKeyboardActionKind,
type KeyboardShortcutPayload,
} from "@/keyboard/actions";
import {
canToggleFileExplorerShortcut,
resolveSelectedOrRouteAgentKey,
} from "@/keyboard/keyboard-shortcut-routing";
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -121,18 +119,38 @@ export function useKeyboardShortcuts({
return true;
};
const requestMessageInputAction = (
const dispatchMessageInputAction = (
kind: MessageInputKeyboardActionKind
): boolean => {
const agentKey = resolveSelectedOrRouteAgentKey({ selectedAgentId, pathname });
if (!agentKey) {
return false;
switch (kind) {
case "focus":
return keyboardActionDispatcher.dispatch({
id: "message-input.focus",
scope: "message-input",
});
case "dictation-toggle":
return keyboardActionDispatcher.dispatch({
id: "message-input.dictation-toggle",
scope: "message-input",
});
case "dictation-cancel":
return keyboardActionDispatcher.dispatch({
id: "message-input.dictation-cancel",
scope: "message-input",
});
case "voice-toggle":
return keyboardActionDispatcher.dispatch({
id: "message-input.voice-toggle",
scope: "message-input",
});
case "voice-mute-toggle":
return keyboardActionDispatcher.dispatch({
id: "message-input.voice-mute-toggle",
scope: "message-input",
});
default:
return false;
}
useKeyboardShortcutsStore.getState().requestMessageInputAction({
agentKey,
kind,
});
return true;
};
const requestWorkspaceTabAction = (input:
| { kind: "new" | "close-current" }
@@ -232,7 +250,7 @@ export function useKeyboardShortcuts({
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
return false;
}
return requestMessageInputAction(input.payload.kind);
return dispatchMessageInputAction(input.payload.kind);
default:
return false;
}
@@ -0,0 +1,152 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createKeyboardActionDispatcher,
type KeyboardActionDefinition,
} from "./keyboard-action-dispatcher";
describe("keyboard-action-dispatcher", () => {
let dispatcher: ReturnType<typeof createKeyboardActionDispatcher>;
beforeEach(() => {
dispatcher = createKeyboardActionDispatcher();
});
it("dispatches to the highest-priority active handler", () => {
const calls: string[] = [];
const action: KeyboardActionDefinition = {
id: "message-input.dictation-toggle",
scope: "message-input",
};
dispatcher.registerHandler({
handlerId: "low",
actions: [action.id],
enabled: true,
priority: 100,
isActive: () => true,
handle: () => {
calls.push("low");
return true;
},
});
dispatcher.registerHandler({
handlerId: "high",
actions: [action.id],
enabled: true,
priority: 200,
isActive: () => true,
handle: () => {
calls.push("high");
return true;
},
});
const handled = dispatcher.dispatch(action);
expect(handled).toBe(true);
expect(calls).toEqual(["high"]);
});
it("skips disabled and inactive handlers", () => {
const handle = vi.fn(() => true);
const action: KeyboardActionDefinition = {
id: "message-input.dictation-toggle",
scope: "message-input",
};
dispatcher.registerHandler({
handlerId: "disabled",
actions: [action.id],
enabled: false,
priority: 300,
isActive: () => true,
handle,
});
dispatcher.registerHandler({
handlerId: "inactive",
actions: [action.id],
enabled: true,
priority: 200,
isActive: () => false,
handle,
});
dispatcher.registerHandler({
handlerId: "active",
actions: [action.id],
enabled: true,
priority: 100,
isActive: () => true,
handle,
});
const handled = dispatcher.dispatch(action);
expect(handled).toBe(true);
expect(handle).toHaveBeenCalledTimes(1);
});
it("tries lower-priority handlers when a higher one does not consume the action", () => {
const calls: string[] = [];
const action: KeyboardActionDefinition = {
id: "message-input.focus",
scope: "message-input",
};
dispatcher.registerHandler({
handlerId: "first",
actions: [action.id],
enabled: true,
priority: 200,
isActive: () => true,
handle: () => {
calls.push("first");
return false;
},
});
dispatcher.registerHandler({
handlerId: "second",
actions: [action.id],
enabled: true,
priority: 100,
isActive: () => true,
handle: () => {
calls.push("second");
return true;
},
});
const handled = dispatcher.dispatch(action);
expect(handled).toBe(true);
expect(calls).toEqual(["first", "second"]);
});
it("does not dispatch after a handler is unregistered", () => {
const handle = vi.fn(() => true);
const action: KeyboardActionDefinition = {
id: "message-input.dictation-cancel",
scope: "message-input",
};
const unregister = dispatcher.registerHandler({
handlerId: "handler",
actions: [action.id],
enabled: true,
priority: 100,
isActive: () => true,
handle,
});
unregister();
const handled = dispatcher.dispatch(action);
expect(handled).toBe(false);
expect(handle).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,75 @@
export type KeyboardActionScope =
| "global"
| "message-input"
| "sidebar"
| "workspace";
export type KeyboardActionId =
| "message-input.focus"
| "message-input.dictation-toggle"
| "message-input.dictation-cancel"
| "message-input.voice-toggle"
| "message-input.voice-mute-toggle";
export type KeyboardActionDefinition = {
id: KeyboardActionId;
scope: KeyboardActionScope;
};
export type KeyboardActionHandler = {
handlerId: string;
actions: readonly KeyboardActionId[];
enabled: boolean;
priority: number;
isActive?: () => boolean;
handle: (action: KeyboardActionDefinition) => boolean;
};
type KeyboardActionRegistryEntry = KeyboardActionHandler & {
registeredAt: number;
};
export function createKeyboardActionDispatcher() {
let nextRegistrationOrder = 1;
const handlers = new Map<string, KeyboardActionRegistryEntry>();
return {
registerHandler(handler: KeyboardActionHandler) {
handlers.set(handler.handlerId, {
...handler,
registeredAt: nextRegistrationOrder++,
});
return () => {
const current = handlers.get(handler.handlerId);
if (!current) {
return;
}
handlers.delete(handler.handlerId);
};
},
dispatch(action: KeyboardActionDefinition): boolean {
const candidates = Array.from(handlers.values())
.filter((handler) => handler.actions.includes(action.id))
.filter((handler) => handler.enabled)
.filter((handler) => (handler.isActive ? handler.isActive() : true))
.sort((left, right) => {
if (left.priority !== right.priority) {
return right.priority - left.priority;
}
return right.registeredAt - left.registeredAt;
});
for (const handler of candidates) {
if (handler.handle(action)) {
return true;
}
}
return false;
},
};
}
export const keyboardActionDispatcher = createKeyboardActionDispatcher();
@@ -1,30 +1,8 @@
import { describe, expect, it } from "vitest";
import {
canToggleFileExplorerShortcut,
resolveSelectedOrRouteAgentKey,
} from "./keyboard-shortcut-routing";
import { canToggleFileExplorerShortcut } from "./keyboard-shortcut-routing";
describe("keyboard-shortcut-routing", () => {
describe("resolveSelectedOrRouteAgentKey", () => {
it("returns selected agent key when provided", () => {
const key = resolveSelectedOrRouteAgentKey({
selectedAgentId: "server-1:agent-1",
pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123",
});
expect(key).toBe("server-1:agent-1");
});
it("maps workspace draft tabs to the draft tab id key", () => {
const key = resolveSelectedOrRouteAgentKey({
pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123",
});
expect(key).toBe("server-1:draft_123");
});
});
describe("canToggleFileExplorerShortcut", () => {
const toggleFileExplorer = () => undefined;
@@ -1,37 +1,8 @@
import {
parseHostAgentRouteFromPathname,
parseHostWorkspaceOpenIntentFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
export function resolveSelectedOrRouteAgentKey(input: {
selectedAgentId?: string;
pathname: string;
}): string | null {
if (input.selectedAgentId) {
return input.selectedAgentId;
}
const workspaceRoute = parseHostWorkspaceRouteFromPathname(input.pathname);
const workspaceOpenIntent = parseHostWorkspaceOpenIntentFromPathname(input.pathname);
if (workspaceRoute && workspaceOpenIntent) {
if (workspaceOpenIntent.kind === "agent") {
const agentId = workspaceOpenIntent.agentId.trim();
return agentId ? `${workspaceRoute.serverId}:${agentId}` : null;
}
if (workspaceOpenIntent.kind === "draft") {
const draftId = workspaceOpenIntent.draftId.trim();
return draftId ? `${workspaceRoute.serverId}:${draftId}` : null;
}
}
const route = parseHostAgentRouteFromPathname(input.pathname);
if (!route) {
return null;
}
return `${route.serverId}:${route.agentId}`;
}
export function canToggleFileExplorerShortcut(input: {
selectedAgentId?: string;
pathname: string;
@@ -55,7 +55,7 @@ import {
} from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import { shouldClearAgentAttention } from "@/utils/agent-attention";
import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
@@ -432,6 +432,8 @@ function AgentScreenContent({
});
const reconnectToastArmedRef = useRef(false);
const initAttemptTokenRef = useRef(0);
const attentionClientRef = useRef(client);
const attentionConnectedRef = useRef(isConnected);
const setFocusedAgentId = useCallback(
(agentId: string | null) => {
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
@@ -439,6 +441,11 @@ function AgentScreenContent({
[serverId]
);
useEffect(() => {
attentionClientRef.current = client;
attentionConnectedRef.current = isConnected;
}, [client, isConnected]);
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
@@ -520,9 +527,25 @@ function AgentScreenContent({
setFocusedAgentId(resolvedAgentId);
return () => {
const latestClient = attentionClientRef.current;
const latestAgent = useSessionStore
.getState()
.sessions[serverId]
?.agents.get(resolvedAgentId);
if (
latestClient &&
shouldClearAgentAttention({
agentId: resolvedAgentId,
isConnected: attentionConnectedRef.current,
requiresAttention: latestAgent?.requiresAttention,
attentionReason: latestAgent?.attentionReason,
})
) {
latestClient.clearAgentAttention(resolvedAgentId);
}
setFocusedAgentId(null);
};
}, [resolvedAgentId, setFocusedAgentId]);
}, [resolvedAgentId, serverId, setFocusedAgentId]);
const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false;
const isHistorySyncing = useMemo(() => {
@@ -818,32 +841,6 @@ function AgentScreenContent({
document.title = title;
}, [agent?.title]);
// Clear attention as soon as the user is focused on this agent screen.
useEffect(() => {
const clearAgentId = resolvedAgentId?.trim();
if (!clearAgentId || !client) {
return;
}
if (
!shouldClearAgentAttentionOnView({
agentId: clearAgentId,
focusedAgentId,
isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
})
) {
return;
}
client.clearAgentAttention(clearAgentId);
}, [
agent?.requiresAttention,
client,
focusedAgentId,
isConnected,
resolvedAgentId,
]);
const isHistoryRefreshCatchingUp =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
@@ -9,8 +9,6 @@ beforeEach(() => {
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
visibleWorkspaceTargets: [],
messageInputActionRequest: null,
nextMessageInputActionRequestId: 1,
workspaceTabActionRequest: null,
nextWorkspaceTabActionRequestId: 1,
});
@@ -1,13 +1,6 @@
import { create } from "zustand";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
export type MessageInputActionRequest = {
id: number;
agentKey: string;
kind: MessageInputKeyboardActionKind;
};
export type WorkspaceTabActionRequest =
| {
id: number;
@@ -39,8 +32,6 @@ interface KeyboardShortcutsState {
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
/** All visible workspace targets in top-to-bottom visual order. */
visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
messageInputActionRequest: MessageInputActionRequest | null;
nextMessageInputActionRequestId: number;
workspaceTabActionRequest: WorkspaceTabActionRequest | null;
nextWorkspaceTabActionRequestId: number;
@@ -52,12 +43,6 @@ interface KeyboardShortcutsState {
setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
resetModifiers: () => void;
requestMessageInputAction: (input: {
agentKey: string;
kind: MessageInputKeyboardActionKind;
}) => void;
clearMessageInputActionRequest: (id: number) => void;
requestWorkspaceTabAction: (input:
| {
serverId: string;
@@ -87,8 +72,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
cmdOrCtrlDown: false,
sidebarShortcutWorkspaceTargets: [],
visibleWorkspaceTargets: [],
messageInputActionRequest: null,
nextMessageInputActionRequestId: 1,
workspaceTabActionRequest: null,
nextWorkspaceTabActionRequestId: 1,
@@ -101,21 +84,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
requestMessageInputAction: ({ agentKey, kind }) => {
const id = get().nextMessageInputActionRequestId;
set({
messageInputActionRequest: { id, agentKey, kind },
nextMessageInputActionRequestId: id + 1,
});
},
clearMessageInputActionRequest: (id) => {
const current = get().messageInputActionRequest;
if (!current || current.id !== id) {
return;
}
set({ messageInputActionRequest: null });
},
requestWorkspaceTabAction: (input) => {
const id = get().nextWorkspaceTabActionRequestId;
set({
+8 -25
View File
@@ -1,13 +1,12 @@
import { describe, expect, it } from "vitest";
import { shouldClearAgentAttentionOnView } from "./agent-attention";
import { shouldClearAgentAttention } from "./agent-attention";
describe("shouldClearAgentAttentionOnView", () => {
it("returns true only when the viewed agent is focused, connected, and requires attention", () => {
describe("shouldClearAgentAttention", () => {
it("returns true only when the agent is connected and requires attention", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
@@ -17,9 +16,8 @@ describe("shouldClearAgentAttentionOnView", () => {
it("returns false when the app is disconnected", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: false,
requiresAttention: true,
attentionReason: "finished",
@@ -27,23 +25,10 @@ describe("shouldClearAgentAttentionOnView", () => {
).toBe(false);
});
it("returns false when the agent is not focused", () => {
expect(
shouldClearAgentAttentionOnView({
agentId: "agent-1",
focusedAgentId: "agent-2",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
})
).toBe(false);
});
it("returns false when attention is already clear", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: false,
attentionReason: null,
@@ -53,9 +38,8 @@ describe("shouldClearAgentAttentionOnView", () => {
it("returns false for empty agent ids", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "finished",
@@ -65,9 +49,8 @@ describe("shouldClearAgentAttentionOnView", () => {
it("returns false for permission-shaped attention", () => {
expect(
shouldClearAgentAttentionOnView({
shouldClearAgentAttention({
agentId: "agent-1",
focusedAgentId: "agent-1",
isConnected: true,
requiresAttention: true,
attentionReason: "permission",
+4 -5
View File
@@ -1,13 +1,12 @@
interface ShouldClearAgentAttentionOnViewInput {
interface ShouldClearAgentAttentionInput {
agentId: string | null | undefined;
focusedAgentId: string | null | undefined;
isConnected: boolean;
requiresAttention: boolean | null | undefined;
attentionReason?: "finished" | "error" | "permission" | null | undefined;
}
export function shouldClearAgentAttentionOnView(
input: ShouldClearAgentAttentionOnViewInput
export function shouldClearAgentAttention(
input: ShouldClearAgentAttentionInput
): boolean {
const agentId = input.agentId?.trim();
if (!agentId) {
@@ -22,5 +21,5 @@ export function shouldClearAgentAttentionOnView(
if (input.attentionReason === "permission") {
return false;
}
return input.focusedAgentId === agentId;
return true;
}