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 { 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 { createTempGitRepo } from './helpers/workspace';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
buildHostWorkspaceAgentRoute,
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
|
} from '@/utils/host-routes';
|
||||||
|
import {
|
||||||
|
ensureWorkspaceAgentPaneVisible,
|
||||||
|
waitForWorkspaceTabsVisible,
|
||||||
|
} from './helpers/workspace-tabs';
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
@@ -110,6 +125,89 @@ async function addFakeMicrophone(page: Page) {
|
|||||||
}, { base64Audio, mimeType });
|
}, { 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 }) => {
|
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
||||||
await addFakeMicrophone(page);
|
await addFakeMicrophone(page);
|
||||||
|
|
||||||
@@ -119,23 +217,18 @@ test('dictation hotkeys do not trigger on background screens', async ({ page })
|
|||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
await setWorkingDirectory(page, repo.path);
|
await setWorkingDirectory(page, repo.path);
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
await createAgent(page, 'Respond with exactly: Hello');
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//);
|
||||||
|
await expectComposerReady(page);
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/agent\//);
|
await openSettings(page);
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
await page.keyboard.press('Control+d');
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||||
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
||||||
|
|
||||||
expect(calls).toBe(1);
|
expect(calls).toBe(0);
|
||||||
expect(active).toBe(1);
|
expect(active).toBe(0);
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await expect
|
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(0);
|
|
||||||
} finally {
|
} finally {
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
}
|
}
|
||||||
@@ -150,23 +243,18 @@ test('dictation transcribes fixture via real STT', async ({ page }) => {
|
|||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
await setWorkingDirectory(page, repo.path);
|
await setWorkingDirectory(page, repo.path);
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
await createAgent(page, 'Respond with exactly: Hello');
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//);
|
||||||
await expect(page).toHaveURL(/\/agent\//);
|
await expectComposerReady(page);
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
await page.keyboard.press('Control+d');
|
||||||
await expect
|
await expectDictationStarted(page);
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(1);
|
|
||||||
|
|
||||||
const initialCopyMessageCount = await page
|
const initialCopyMessageCount = await page
|
||||||
.getByRole('button', { name: 'Copy message' })
|
.getByRole('button', { name: 'Copy message' })
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
await page.keyboard.press('Control+d');
|
||||||
await expect
|
await expectDictationStopped(page);
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(0);
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -189,13 +277,11 @@ test('cancel stops mic even if recorder is already inactive', async ({ page }) =
|
|||||||
await setWorkingDirectory(page, repo.path);
|
await setWorkingDirectory(page, repo.path);
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
await createAgent(page, 'Respond with exactly: Hello');
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/agent\//);
|
await expect(page).toHaveURL(/\/workspace\//);
|
||||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
await expectComposerReady(page);
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
await page.keyboard.press('Control+d');
|
||||||
await expect
|
await expectDictationStarted(page);
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(1);
|
|
||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
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 page.keyboard.press('Escape');
|
||||||
await expect
|
await expectDictationStopped(page);
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(0);
|
|
||||||
} finally {
|
} finally {
|
||||||
await repo.cleanup();
|
await repo.cleanup();
|
||||||
}
|
}
|
||||||
@@ -223,35 +307,31 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
|
|||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
await createAgent(page, 'Respond with exactly: Hello');
|
await createAgent(page, 'Respond with exactly: Hello');
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/agent($|\/)/);
|
await expect(page).toHaveURL(/\/workspace\//);
|
||||||
const match = page.url().match(/\/h\/([^/]+)\/agent\/([^/?#]+)/);
|
const match = page.url().match(/\/h\/([^/]+)\/workspace\/[^?]+(?:\?open=agent%3A|\\?open=agent:)([^/?#&]+)/);
|
||||||
if (!match) {
|
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 serverId = decodeURIComponent(match[1]!);
|
||||||
const agentId = decodeURIComponent(match[2]!);
|
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();
|
const initialCopyMessageCount = await page.getByRole('button', { name: 'Copy message' }).count();
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
await page.keyboard.press('Control+d');
|
||||||
await expect
|
await expectDictationStarted(page);
|
||||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
|
||||||
.toBe(1);
|
|
||||||
|
|
||||||
await page.keyboard.press('Control+d');
|
await page.keyboard.press('Control+d');
|
||||||
|
|
||||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
||||||
await expect(newAgentButton).toBeVisible();
|
await expect(newAgentButton).toBeVisible();
|
||||||
await newAgentButton.click();
|
await newAgentButton.click();
|
||||||
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/);
|
await expect(page).toHaveURL(/\/h\/[^/]+\/new-agent(\?|$)/);
|
||||||
|
|
||||||
await page.waitForTimeout(10_000);
|
await page.waitForTimeout(10_000);
|
||||||
|
|
||||||
const agentEntry = page.getByTestId(`agent-row-${serverId}-${agentId}`).first();
|
await page.goto(buildHostWorkspaceAgentRoute(serverId, repo.path, agentId));
|
||||||
await expect(agentEntry).toBeVisible();
|
|
||||||
await agentEntry.click();
|
|
||||||
await expect(page).toHaveURL(
|
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);
|
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.fill(message);
|
||||||
await input.press('Enter');
|
await input.press('Enter');
|
||||||
|
|
||||||
// Router updates can be same-document transitions; assert URL state instead of waiting for a commit event.
|
// The composer may remain on the draft screen briefly while the initial run starts,
|
||||||
await expect(page).toHaveURL(/\/(workspace|agent)\//, { timeout: 30000 });
|
// 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({
|
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
import { Theme } from '@/styles/theme'
|
import { Theme } from '@/styles/theme'
|
||||||
import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query'
|
import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query'
|
||||||
import { encodeImages } from '@/utils/encode-images'
|
import { encodeImages } from '@/utils/encode-images'
|
||||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
|
||||||
import { focusWithRetries } from '@/utils/web-focus'
|
import { focusWithRetries } from '@/utils/web-focus'
|
||||||
import { useVoiceOptional } from '@/contexts/voice-context'
|
import { useVoiceOptional } from '@/contexts/voice-context'
|
||||||
import { useToast } from '@/contexts/toast-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 { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
|
||||||
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
|
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
|
||||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
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 = {
|
type QueuedMessage = {
|
||||||
id: string
|
id: string
|
||||||
@@ -89,10 +91,6 @@ export function AgentInputArea({
|
|||||||
const { theme } = useUnistyles()
|
const { theme } = useUnistyles()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const isScreenFocused = useIsFocused()
|
const isScreenFocused = useIsFocused()
|
||||||
const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest)
|
|
||||||
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
|
|
||||||
(s) => s.clearMessageInputActionRequest
|
|
||||||
)
|
|
||||||
|
|
||||||
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
|
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -134,10 +132,13 @@ export function AgentInputArea({
|
|||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([])
|
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([])
|
||||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false)
|
const [isCancellingAgent, setIsCancellingAgent] = useState(false)
|
||||||
const [sendError, setSendError] = useState<string | null>(null)
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null)
|
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false)
|
||||||
const messageInputRef = useRef<MessageInputRef>(null)
|
const messageInputRef = useRef<MessageInputRef>(null)
|
||||||
const draftGenerationRef = useRef(0)
|
const draftGenerationRef = useRef(0)
|
||||||
const hydratedGenerationRef = useRef(0)
|
const hydratedGenerationRef = useRef(0)
|
||||||
|
const keyboardHandlerIdRef = useRef(
|
||||||
|
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`
|
||||||
|
)
|
||||||
|
|
||||||
const autocomplete = useAgentAutocomplete({
|
const autocomplete = useAgentAutocomplete({
|
||||||
userInput,
|
userInput,
|
||||||
@@ -240,8 +241,18 @@ export function AgentInputArea({
|
|||||||
messageId: clientMessageId,
|
messageId: clientMessageId,
|
||||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
...(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(() => {
|
useEffect(() => {
|
||||||
onSubmitMessageRef.current = onSubmitMessage
|
onSubmitMessageRef.current = onSubmitMessage
|
||||||
@@ -513,50 +524,58 @@ export function AgentInputArea({
|
|||||||
userInput,
|
userInput,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Keyboard-dispatched message-input actions are routed through store requests.
|
const handleKeyboardAction = useCallback((action: KeyboardActionDefinition): boolean => {
|
||||||
useEffect(() => {
|
if (!isScreenFocused) {
|
||||||
if (!isScreenFocused) return
|
return false
|
||||||
if (!messageInputActionRequest) return
|
|
||||||
|
|
||||||
const currentKey = `${serverId}:${agentId}`
|
|
||||||
if (messageInputActionRequest.agentKey !== currentKey) {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastHandledMessageInputActionRequestIdRef.current === messageInputActionRequest.id) {
|
switch (action.id) {
|
||||||
return
|
case 'message-input.focus':
|
||||||
}
|
if (Platform.OS !== 'web') {
|
||||||
lastHandledMessageInputActionRequestIdRef.current = messageInputActionRequest.id
|
messageInputRef.current?.focus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (messageInputActionRequest.kind !== 'focus') {
|
focusWithRetries({
|
||||||
messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind)
|
focus: () => messageInputRef.current?.focus(),
|
||||||
clearMessageInputActionRequest(messageInputActionRequest.id)
|
isFocused: () => {
|
||||||
return
|
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') {
|
useKeyboardActionHandler({
|
||||||
messageInputRef.current?.focus()
|
handlerId: keyboardHandlerIdRef.current,
|
||||||
clearMessageInputActionRequest(messageInputActionRequest.id)
|
actions: [
|
||||||
return
|
'message-input.focus',
|
||||||
}
|
'message-input.dictation-toggle',
|
||||||
|
'message-input.dictation-cancel',
|
||||||
return focusWithRetries({
|
'message-input.voice-toggle',
|
||||||
focus: () => messageInputRef.current?.focus(),
|
'message-input.voice-mute-toggle',
|
||||||
isFocused: () => {
|
],
|
||||||
const el = messageInputRef.current?.getNativeElement?.() ?? null
|
enabled: isScreenFocused,
|
||||||
const active = typeof document !== 'undefined' ? document.activeElement : null
|
priority: isMessageInputFocused ? 200 : 100,
|
||||||
return Boolean(el) && active === el
|
isActive: () => isScreenFocused,
|
||||||
},
|
handle: handleKeyboardAction,
|
||||||
onSuccess: () => clearMessageInputActionRequest(messageInputActionRequest.id),
|
})
|
||||||
onTimeout: () => clearMessageInputActionRequest(messageInputActionRequest.id),
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
agentId,
|
|
||||||
clearMessageInputActionRequest,
|
|
||||||
isScreenFocused,
|
|
||||||
messageInputActionRequest,
|
|
||||||
serverId,
|
|
||||||
])
|
|
||||||
|
|
||||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
|
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
|
||||||
mode: 'translate',
|
mode: 'translate',
|
||||||
@@ -803,6 +822,7 @@ export function AgentInputArea({
|
|||||||
onSelectionChange={(selection) => {
|
onSelectionChange={(selection) => {
|
||||||
setCursorIndex(selection.start)
|
setCursorIndex(selection.start)
|
||||||
}}
|
}}
|
||||||
|
onFocusChange={setIsMessageInputFocused}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export interface MessageInputProps {
|
|||||||
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean
|
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean
|
||||||
/** Reports cursor selection updates from the underlying input. */
|
/** Reports cursor selection updates from the underlying input. */
|
||||||
onSelectionChange?: (selection: { start: number; end: number }) => void
|
onSelectionChange?: (selection: { start: number; end: number }) => void
|
||||||
|
onFocusChange?: (focused: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageInputRef {
|
export interface MessageInputRef {
|
||||||
@@ -151,6 +152,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onSubmitLoadingPress,
|
onSubmitLoadingPress,
|
||||||
onKeyPress: onKeyPressCallback,
|
onKeyPress: onKeyPressCallback,
|
||||||
onSelectionChange: onSelectionChangeCallback,
|
onSelectionChange: onSelectionChangeCallback,
|
||||||
|
onFocusChange,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -234,6 +236,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
onFocusChange?.(false)
|
||||||
|
}
|
||||||
|
}, [onFocusChange])
|
||||||
|
|
||||||
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || !autoFocus) return
|
if (!IS_WEB || !autoFocus) return
|
||||||
@@ -707,9 +715,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
placeholderTextColor={theme.colors.mutedForeground}
|
placeholderTextColor={theme.colors.mutedForeground}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
isInputFocusedRef.current = true
|
isInputFocusedRef.current = true
|
||||||
|
onFocusChange?.(true)
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
isInputFocusedRef.current = false
|
isInputFocusedRef.current = false
|
||||||
|
onFocusChange?.(false)
|
||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
styles.textInput,
|
styles.textInput,
|
||||||
|
|||||||
@@ -1181,7 +1181,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
},
|
},
|
||||||
workspaceListContainer: {
|
workspaceListContainer: {
|
||||||
marginLeft: theme.spacing[2],
|
marginLeft: 0,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -1192,7 +1192,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
projectRow: {
|
projectRow: {
|
||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -1253,7 +1253,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 36,
|
minHeight: 36,
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -1329,6 +1329,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
workspaceBranchText: {
|
workspaceBranchText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
opacity: 0.76,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,45 @@
|
|||||||
type TimelineDirection = "tail" | "before" | "after";
|
type TimelineDirection = "tail" | "before" | "after";
|
||||||
type InitRequestDirection = "tail" | "after";
|
type InitRequestDirection = "tail" | "after";
|
||||||
|
|
||||||
type TimelineCursor = {
|
type BootstrapTailCursor = {
|
||||||
seq: number;
|
seq: number;
|
||||||
} | null;
|
} | 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({
|
export function deriveBootstrapTailTimelinePolicy({
|
||||||
direction,
|
direction,
|
||||||
reset,
|
reset,
|
||||||
@@ -16,7 +51,7 @@ export function deriveBootstrapTailTimelinePolicy({
|
|||||||
direction: TimelineDirection;
|
direction: TimelineDirection;
|
||||||
reset: boolean;
|
reset: boolean;
|
||||||
epoch: string;
|
epoch: string;
|
||||||
endCursor: TimelineCursor;
|
endCursor: BootstrapTailCursor;
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
hasActiveInitDeferred: boolean;
|
hasActiveInitDeferred: boolean;
|
||||||
}): {
|
}): {
|
||||||
|
|||||||
@@ -2,11 +2,28 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { __private__ } from "./use-agent-initialization";
|
import { __private__ } from "./use-agent-initialization";
|
||||||
|
|
||||||
describe("useAgentInitialization timeline request policy", () => {
|
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", () => {
|
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||||
expect(
|
expect(
|
||||||
__private__.buildInitialTimelineRequest({
|
__private__.deriveInitialTimelineRequest({
|
||||||
cursor: undefined,
|
cursor: null,
|
||||||
hasLocalTail: false,
|
hasAuthoritativeHistory: true,
|
||||||
initialTimelineLimit: 200,
|
initialTimelineLimit: 200,
|
||||||
})
|
})
|
||||||
).toEqual({
|
).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(
|
expect(
|
||||||
__private__.buildInitialTimelineRequest({
|
__private__.deriveInitialTimelineRequest({
|
||||||
cursor: {
|
cursor: {
|
||||||
epoch: "epoch-1",
|
epoch: "epoch-1",
|
||||||
endSeq: 42,
|
seq: 42,
|
||||||
},
|
},
|
||||||
hasLocalTail: false,
|
hasAuthoritativeHistory: true,
|
||||||
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,
|
|
||||||
initialTimelineLimit: 200,
|
initialTimelineLimit: 200,
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
@@ -53,9 +53,9 @@ describe("useAgentInitialization timeline request policy", () => {
|
|||||||
|
|
||||||
it("supports unbounded tail bootstrap policy", () => {
|
it("supports unbounded tail bootstrap policy", () => {
|
||||||
expect(
|
expect(
|
||||||
__private__.buildInitialTimelineRequest({
|
__private__.deriveInitialTimelineRequest({
|
||||||
cursor: undefined,
|
cursor: null,
|
||||||
hasLocalTail: false,
|
hasAuthoritativeHistory: false,
|
||||||
initialTimelineLimit: 0,
|
initialTimelineLimit: 0,
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Platform } from "react-native";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type {
|
import type {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
FetchAgentTimelineOptions,
|
|
||||||
} from "@server/client/daemon-client";
|
} from "@server/client/daemon-client";
|
||||||
import {
|
import {
|
||||||
attachInitTimeout,
|
attachInitTimeout,
|
||||||
@@ -12,43 +11,12 @@ import {
|
|||||||
getInitKey,
|
getInitKey,
|
||||||
rejectInitDeferred,
|
rejectInitDeferred,
|
||||||
} from "@/utils/agent-initialization";
|
} from "@/utils/agent-initialization";
|
||||||
|
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
|
||||||
|
|
||||||
const INIT_TIMEOUT_MS = 5 * 60_000;
|
const INIT_TIMEOUT_MS = 5 * 60_000;
|
||||||
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
|
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
|
||||||
const UNBOUNDED_TIMELINE_LIMIT = 0;
|
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 {
|
function resolveInitialTimelineLimit(): number {
|
||||||
return Platform.OS === "web"
|
return Platform.OS === "web"
|
||||||
? UNBOUNDED_TIMELINE_LIMIT
|
? UNBOUNDED_TIMELINE_LIMIT
|
||||||
@@ -56,7 +24,7 @@ function resolveInitialTimelineLimit(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const __private__ = {
|
export const __private__ = {
|
||||||
buildInitialTimelineRequest,
|
deriveInitialTimelineRequest,
|
||||||
resolveInitialTimelineLimit,
|
resolveInitialTimelineLimit,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,11 +60,14 @@ export function useAgentInitialization({
|
|||||||
|
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
const session = useSessionStore.getState().sessions[serverId];
|
||||||
const cursor = session?.agentTimelineCursor.get(agentId);
|
const cursor = session?.agentTimelineCursor.get(agentId);
|
||||||
const hasLocalTail = (session?.agentStreamTail.get(agentId)?.length ?? 0) > 0;
|
|
||||||
const initialTimelineLimit = resolveInitialTimelineLimit();
|
const initialTimelineLimit = resolveInitialTimelineLimit();
|
||||||
const timelineRequest = buildInitialTimelineRequest({
|
const hasAuthoritativeHistory =
|
||||||
cursor,
|
(session?.agentHistorySyncGeneration.get(agentId) ?? -1) >= 0;
|
||||||
hasLocalTail,
|
const timelineRequest = deriveInitialTimelineRequest({
|
||||||
|
cursor: cursor
|
||||||
|
? { epoch: cursor.epoch, seq: cursor.endSeq }
|
||||||
|
: null,
|
||||||
|
hasAuthoritativeHistory,
|
||||||
initialTimelineLimit,
|
initialTimelineLimit,
|
||||||
});
|
});
|
||||||
const initRequestDirection =
|
const initRequestDirection =
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import type { TextInput } from "react-native";
|
import type { TextInput } from "react-native";
|
||||||
import { router, usePathname, type Href } from "expo-router";
|
import { router, usePathname, type Href } from "expo-router";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import {
|
import {
|
||||||
clearCommandCenterFocusRestoreElement,
|
clearCommandCenterFocusRestoreElement,
|
||||||
@@ -17,10 +18,6 @@ import {
|
|||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
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 {
|
function isMatch(agent: AggregatedAgent, query: string): boolean {
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
@@ -46,14 +43,6 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
|||||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
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 = {
|
type CommandCenterActionDefinition = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -117,9 +106,6 @@ export function useCommandCenter() {
|
|||||||
const { agents } = useAggregatedAgents();
|
const { agents } = useAggregatedAgents();
|
||||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||||
const requestMessageInputAction = useKeyboardShortcutsStore(
|
|
||||||
(s) => s.requestMessageInputAction
|
|
||||||
);
|
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const didNavigateRef = useRef(false);
|
const didNavigateRef = useRef(false);
|
||||||
const prevOpenRef = useRef(open);
|
const prevOpenRef = useRef(open);
|
||||||
@@ -134,11 +120,6 @@ export function useCommandCenter() {
|
|||||||
|
|
||||||
const fallbackServerId = agents[0]?.serverId ?? null;
|
const fallbackServerId = agents[0]?.serverId ?? null;
|
||||||
|
|
||||||
const agentKeyFromPathname = useMemo(
|
|
||||||
() => parseAgentKeyFromPathname(pathname),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newAgentRoute = useMemo<Href>(() => {
|
const newAgentRoute = useMemo<Href>(() => {
|
||||||
const serverIdFromPath =
|
const serverIdFromPath =
|
||||||
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
parseServerIdFromPathname(pathname) ?? fallbackServerId;
|
||||||
@@ -191,10 +172,6 @@ export function useCommandCenter() {
|
|||||||
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname));
|
||||||
const navigate = shouldReplace ? router.replace : router.push;
|
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.
|
// Don't restore focus back to the prior element after we navigate.
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -205,7 +182,7 @@ export function useCommandCenter() {
|
|||||||
) as Href;
|
) as Href;
|
||||||
navigate(route);
|
navigate(route);
|
||||||
},
|
},
|
||||||
[pathname, requestMessageInputAction, setOpen]
|
[pathname, setOpen]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||||
@@ -245,12 +222,10 @@ export function useCommandCenter() {
|
|||||||
focus: () => el?.focus(),
|
focus: () => el?.focus(),
|
||||||
isFocused,
|
isFocused,
|
||||||
onTimeout: () => {
|
onTimeout: () => {
|
||||||
if (agentKeyFromPathname) {
|
keyboardActionDispatcher.dispatch({
|
||||||
requestMessageInputAction({
|
id: "message-input.focus",
|
||||||
agentKey: agentKeyFromPathname,
|
scope: "message-input",
|
||||||
kind: "focus",
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return cancel;
|
return cancel;
|
||||||
@@ -265,7 +240,7 @@ export function useCommandCenter() {
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [agentKeyFromPathname, open, requestMessageInputAction]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
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 MessageInputKeyboardActionKind,
|
||||||
type KeyboardShortcutPayload,
|
type KeyboardShortcutPayload,
|
||||||
} from "@/keyboard/actions";
|
} from "@/keyboard/actions";
|
||||||
import {
|
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
|
||||||
canToggleFileExplorerShortcut,
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
resolveSelectedOrRouteAgentKey,
|
|
||||||
} from "@/keyboard/keyboard-shortcut-routing";
|
|
||||||
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
||||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
@@ -121,18 +119,38 @@ export function useKeyboardShortcuts({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestMessageInputAction = (
|
const dispatchMessageInputAction = (
|
||||||
kind: MessageInputKeyboardActionKind
|
kind: MessageInputKeyboardActionKind
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const agentKey = resolveSelectedOrRouteAgentKey({ selectedAgentId, pathname });
|
switch (kind) {
|
||||||
if (!agentKey) {
|
case "focus":
|
||||||
return false;
|
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:
|
const requestWorkspaceTabAction = (input:
|
||||||
| { kind: "new" | "close-current" }
|
| { kind: "new" | "close-current" }
|
||||||
@@ -232,7 +250,7 @@ export function useKeyboardShortcuts({
|
|||||||
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
|
if (!input.payload || typeof input.payload !== "object" || !("kind" in input.payload)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return requestMessageInputAction(input.payload.kind);
|
return dispatchMessageInputAction(input.payload.kind);
|
||||||
default:
|
default:
|
||||||
return false;
|
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 { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import { canToggleFileExplorerShortcut } from "./keyboard-shortcut-routing";
|
||||||
canToggleFileExplorerShortcut,
|
|
||||||
resolveSelectedOrRouteAgentKey,
|
|
||||||
} from "./keyboard-shortcut-routing";
|
|
||||||
|
|
||||||
describe("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", () => {
|
describe("canToggleFileExplorerShortcut", () => {
|
||||||
const toggleFileExplorer = () => undefined;
|
const toggleFileExplorer = () => undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceOpenIntentFromPathname,
|
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} 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: {
|
export function canToggleFileExplorerShortcut(input: {
|
||||||
selectedAgentId?: string;
|
selectedAgentId?: string;
|
||||||
pathname: string;
|
pathname: string;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import {
|
|||||||
} from "@/utils/agent-snapshots";
|
} from "@/utils/agent-snapshots";
|
||||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
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 type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
import type { ExplorerCheckoutContext } from "@/stores/panel-store";
|
||||||
@@ -432,6 +432,8 @@ function AgentScreenContent({
|
|||||||
});
|
});
|
||||||
const reconnectToastArmedRef = useRef(false);
|
const reconnectToastArmedRef = useRef(false);
|
||||||
const initAttemptTokenRef = useRef(0);
|
const initAttemptTokenRef = useRef(0);
|
||||||
|
const attentionClientRef = useRef(client);
|
||||||
|
const attentionConnectedRef = useRef(isConnected);
|
||||||
const setFocusedAgentId = useCallback(
|
const setFocusedAgentId = useCallback(
|
||||||
(agentId: string | null) => {
|
(agentId: string | null) => {
|
||||||
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
|
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
|
||||||
@@ -439,6 +441,11 @@ function AgentScreenContent({
|
|||||||
[serverId]
|
[serverId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
attentionClientRef.current = client;
|
||||||
|
attentionConnectedRef.current = isConnected;
|
||||||
|
}, [client, isConnected]);
|
||||||
|
|
||||||
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
||||||
mode: "translate",
|
mode: "translate",
|
||||||
});
|
});
|
||||||
@@ -520,9 +527,25 @@ function AgentScreenContent({
|
|||||||
|
|
||||||
setFocusedAgentId(resolvedAgentId);
|
setFocusedAgentId(resolvedAgentId);
|
||||||
return () => {
|
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);
|
setFocusedAgentId(null);
|
||||||
};
|
};
|
||||||
}, [resolvedAgentId, setFocusedAgentId]);
|
}, [resolvedAgentId, serverId, setFocusedAgentId]);
|
||||||
|
|
||||||
const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false;
|
const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false;
|
||||||
const isHistorySyncing = useMemo(() => {
|
const isHistorySyncing = useMemo(() => {
|
||||||
@@ -818,32 +841,6 @@ function AgentScreenContent({
|
|||||||
document.title = title;
|
document.title = title;
|
||||||
}, [agent?.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 =
|
const isHistoryRefreshCatchingUp =
|
||||||
viewState.tag === "ready" &&
|
viewState.tag === "ready" &&
|
||||||
viewState.sync.status === "catching_up" &&
|
viewState.sync.status === "catching_up" &&
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ beforeEach(() => {
|
|||||||
cmdOrCtrlDown: false,
|
cmdOrCtrlDown: false,
|
||||||
sidebarShortcutWorkspaceTargets: [],
|
sidebarShortcutWorkspaceTargets: [],
|
||||||
visibleWorkspaceTargets: [],
|
visibleWorkspaceTargets: [],
|
||||||
messageInputActionRequest: null,
|
|
||||||
nextMessageInputActionRequestId: 1,
|
|
||||||
workspaceTabActionRequest: null,
|
workspaceTabActionRequest: null,
|
||||||
nextWorkspaceTabActionRequestId: 1,
|
nextWorkspaceTabActionRequestId: 1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
|
||||||
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
|
||||||
|
|
||||||
export type MessageInputActionRequest = {
|
|
||||||
id: number;
|
|
||||||
agentKey: string;
|
|
||||||
kind: MessageInputKeyboardActionKind;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WorkspaceTabActionRequest =
|
export type WorkspaceTabActionRequest =
|
||||||
| {
|
| {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -39,8 +32,6 @@ interface KeyboardShortcutsState {
|
|||||||
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||||
/** All visible workspace targets in top-to-bottom visual order. */
|
/** All visible workspace targets in top-to-bottom visual order. */
|
||||||
visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
visibleWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
|
||||||
messageInputActionRequest: MessageInputActionRequest | null;
|
|
||||||
nextMessageInputActionRequestId: number;
|
|
||||||
workspaceTabActionRequest: WorkspaceTabActionRequest | null;
|
workspaceTabActionRequest: WorkspaceTabActionRequest | null;
|
||||||
nextWorkspaceTabActionRequestId: number;
|
nextWorkspaceTabActionRequestId: number;
|
||||||
|
|
||||||
@@ -52,12 +43,6 @@ interface KeyboardShortcutsState {
|
|||||||
setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
setVisibleWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
||||||
resetModifiers: () => void;
|
resetModifiers: () => void;
|
||||||
|
|
||||||
requestMessageInputAction: (input: {
|
|
||||||
agentKey: string;
|
|
||||||
kind: MessageInputKeyboardActionKind;
|
|
||||||
}) => void;
|
|
||||||
clearMessageInputActionRequest: (id: number) => void;
|
|
||||||
|
|
||||||
requestWorkspaceTabAction: (input:
|
requestWorkspaceTabAction: (input:
|
||||||
| {
|
| {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -87,8 +72,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
|||||||
cmdOrCtrlDown: false,
|
cmdOrCtrlDown: false,
|
||||||
sidebarShortcutWorkspaceTargets: [],
|
sidebarShortcutWorkspaceTargets: [],
|
||||||
visibleWorkspaceTargets: [],
|
visibleWorkspaceTargets: [],
|
||||||
messageInputActionRequest: null,
|
|
||||||
nextMessageInputActionRequestId: 1,
|
|
||||||
workspaceTabActionRequest: null,
|
workspaceTabActionRequest: null,
|
||||||
nextWorkspaceTabActionRequestId: 1,
|
nextWorkspaceTabActionRequestId: 1,
|
||||||
|
|
||||||
@@ -101,21 +84,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>(
|
|||||||
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
|
setVisibleWorkspaceTargets: (targets) => set({ visibleWorkspaceTargets: targets }),
|
||||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
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) => {
|
requestWorkspaceTabAction: (input) => {
|
||||||
const id = get().nextWorkspaceTabActionRequestId;
|
const id = get().nextWorkspaceTabActionRequestId;
|
||||||
set({
|
set({
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { shouldClearAgentAttentionOnView } from "./agent-attention";
|
import { shouldClearAgentAttention } from "./agent-attention";
|
||||||
|
|
||||||
describe("shouldClearAgentAttentionOnView", () => {
|
describe("shouldClearAgentAttention", () => {
|
||||||
it("returns true only when the viewed agent is focused, connected, and requires attention", () => {
|
it("returns true only when the agent is connected and requires attention", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldClearAgentAttentionOnView({
|
shouldClearAgentAttention({
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
focusedAgentId: "agent-1",
|
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
attentionReason: "finished",
|
attentionReason: "finished",
|
||||||
@@ -17,9 +16,8 @@ describe("shouldClearAgentAttentionOnView", () => {
|
|||||||
|
|
||||||
it("returns false when the app is disconnected", () => {
|
it("returns false when the app is disconnected", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldClearAgentAttentionOnView({
|
shouldClearAgentAttention({
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
focusedAgentId: "agent-1",
|
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
attentionReason: "finished",
|
attentionReason: "finished",
|
||||||
@@ -27,23 +25,10 @@ describe("shouldClearAgentAttentionOnView", () => {
|
|||||||
).toBe(false);
|
).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", () => {
|
it("returns false when attention is already clear", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldClearAgentAttentionOnView({
|
shouldClearAgentAttention({
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
focusedAgentId: "agent-1",
|
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
@@ -53,9 +38,8 @@ describe("shouldClearAgentAttentionOnView", () => {
|
|||||||
|
|
||||||
it("returns false for empty agent ids", () => {
|
it("returns false for empty agent ids", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldClearAgentAttentionOnView({
|
shouldClearAgentAttention({
|
||||||
agentId: "",
|
agentId: "",
|
||||||
focusedAgentId: "agent-1",
|
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
attentionReason: "finished",
|
attentionReason: "finished",
|
||||||
@@ -65,9 +49,8 @@ describe("shouldClearAgentAttentionOnView", () => {
|
|||||||
|
|
||||||
it("returns false for permission-shaped attention", () => {
|
it("returns false for permission-shaped attention", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldClearAgentAttentionOnView({
|
shouldClearAgentAttention({
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
focusedAgentId: "agent-1",
|
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
requiresAttention: true,
|
requiresAttention: true,
|
||||||
attentionReason: "permission",
|
attentionReason: "permission",
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
interface ShouldClearAgentAttentionOnViewInput {
|
interface ShouldClearAgentAttentionInput {
|
||||||
agentId: string | null | undefined;
|
agentId: string | null | undefined;
|
||||||
focusedAgentId: string | null | undefined;
|
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
requiresAttention: boolean | null | undefined;
|
requiresAttention: boolean | null | undefined;
|
||||||
attentionReason?: "finished" | "error" | "permission" | null | undefined;
|
attentionReason?: "finished" | "error" | "permission" | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldClearAgentAttentionOnView(
|
export function shouldClearAgentAttention(
|
||||||
input: ShouldClearAgentAttentionOnViewInput
|
input: ShouldClearAgentAttentionInput
|
||||||
): boolean {
|
): boolean {
|
||||||
const agentId = input.agentId?.trim();
|
const agentId = input.agentId?.trim();
|
||||||
if (!agentId) {
|
if (!agentId) {
|
||||||
@@ -22,5 +21,5 @@ export function shouldClearAgentAttentionOnView(
|
|||||||
if (input.attentionReason === "permission") {
|
if (input.attentionReason === "permission") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return input.focusedAgentId === agentId;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user