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