refactor: replace store-based keyboard action requests with direct dispatcher and simplify agent attention clearing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user