diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index bf193e02..34d00976 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -13,6 +13,7 @@ The maintained list of what a person can do in the app, and how to prove each on | Feature | File | Scenario | | --- | --- | --- | | Sidebar places: Pinned, More, Hidden, counts on rows (Home's waiting, Review's, a plugin's) | [sidebar-nav.md](sidebar-nav.md) | `plugin-nav-badges` | +| Back and forward: the window strip's arrows, ⌘[ and ⌘], the mouse's buttons; archiving or removing the worktree in front goes back, not Home; the dashboard keeps its scroll | [navigation-history.md](navigation-history.md) | `navigation-history`; `app/e2e/back-forward.spec.ts` asserts it | | Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items) | | Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it | | Questions in chat: an agent's form of questions as a card, answered on a real box; kept with Retry and Copy when it can't be | [chat-questions.md](chat-questions.md) | `chat-questions-live` (a real box, `--agent`); `app/e2e/chat.spec.ts` on the mock | diff --git a/.claude/skills/verify-berthd/features/navigation-history.md b/.claude/skills/verify-berthd/features/navigation-history.md new file mode 100644 index 00000000..f45f88c9 --- /dev/null +++ b/.claude/skills/verify-berthd/features/navigation-history.md @@ -0,0 +1,30 @@ +# Back and forward + +The app keeps a trail of the places you've been, as a browser does (`app/src/lib/nav-history.ts`, its pure part `nav-history-core.ts`). A place is a view (the Agent Dashboard, Review, Worktrees, Settings and its section, a plugin's screen, …), and in the workspace the worktree in front (none is Home; a box's home terminals count) with its tab showing. Switching tabs changes the place you're on rather than adding one. The trail follows the two stores that say where you are (`useStore.view`, `useWorkspaces.current`), so nothing that moves you has to tell it. It holds 50 places and lasts until the window closes. + +## Sub-features + +- `history-arrows`: Back and Forward in the window strip at the sidebar's top (rail and list layouts, `components/sidebar/back-forward.tsx`), before the bell and the fold. Disabled with nowhere to go. +- `history-keys`: ⌘[ and ⌘] (Ctrl+[ and Ctrl+] in the Linux app; in a terminal there they stay the shell's, Ctrl+Shift+[ and ] are the app's). In a File tab's editor they stay outdent and indent. Also Go → Back and Go → Forward in the Mac app's menu bar, ⌘K's Back and Forward, and the Keyboard shortcuts sheet (all from `lib/shortcuts.json`). +- `history-mouse`: a mouse's back (button 3) and forward (4) buttons, on `mouseup`. +- `history-skip`: Back and Forward pass over a worktree that has gone (archived or removed) and over the place you're on already. +- `history-gone`: when the worktree in front is archived or removed and that would leave you on Home, you go back to the last place before it that is still there instead (the Agent Dashboard, say). With Labs tab groups, when another group comes forward it stays in front, as before. +- `dashboard-scroll`: the Agent Dashboard keeps where it was scrolled to until the window closes, so Back (or ⌘J) finds the card you left from. + +## How to get to it (user POV) + +- Open the Agent Dashboard (⌘J), click a card, then the ← at the sidebar's top, or ⌘[. +- Close the session's tab, then **Archive this worktree…** on the launcher: you land on the dashboard. + +## Driving it with drive.mjs + +- `node .claude/skills/verify-berthd/scripts/drive.mjs navigation-history --port $PORT` starts on Home (both arrows disabled), opens the dashboard with ⌘J and scrolls it down, clicks "Speed up product search" (worktree `search-perf` on `devl`), then: Back to the dashboard at the same `scrollTop`, Forward to the session, ⌘[ and ⌘] the same, mouse buttons 3 and 4 the same, ⌘W to close its tab, **Archive this worktree…** and **Archive** in the confirm: it asserts the dashboard shows (not Home), the card is gone and Forward is disabled. +- With Labs on (tab groups, the harbour Home): a two-line scenario that re-exports `run` from `navigation-history.mjs` with `export const params = { labs: "1" }` passes the same steps. +- Hooks: `history-back`, `history-forward` (the arrows, in a group named "History"), `dashboard-board` (the board's scroller). The worktree in front is the page's `h1` (App.tsx `WorkspaceHeading`, or the launcher's title). + +## Gotchas + +- Playwright's mouse has no back or forward button: the scenario dispatches `mouseup` with `button` 3 and 4 on the window, which is what the handler listens for. A real mouse in the Mac app (WKWebView) needs a person. +- The Mac app's menu items and the ⌘[ that reaches a File tab's editor while the menu holds the key can't be shown on the mock; `hooks/use-shortcuts.ts` hands the key to the editor when it has the keyboard. +- The folded sidebar, zen and Settings have no arrows; the keys, the mouse, ⌘K and the menu still work there. +- The mock's `shop` project has an archive script: the box answers at once, the launcher shows Archiving…, and the worktree goes (and you go back) 1.5s later, when `worktree.removed` arrives. Remove worktree, from the sidebar's ⋯, goes the same way, through `forgetWorktree`. diff --git a/.claude/skills/verify-berthd/scripts/scenarios/navigation-history.mjs b/.claude/skills/verify-berthd/scripts/scenarios/navigation-history.mjs new file mode 100644 index 00000000..bbb09d63 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/navigation-history.mjs @@ -0,0 +1,106 @@ +// Back and forward through the places you've been (lib/nav-history.ts): the +// window strip's arrows, ⌘[ and ⌘], a mouse's back and forward buttons, the +// dashboard keeping its scroll, and archiving the worktree in front taking +// you back to the dashboard instead of Home. features/navigation-history.md. + +export async function run({ page, shot, step, expect }) { + const back = page.getByTestId("history-back"); + const forward = page.getByTestId("history-forward"); + const dashboard = page.getByRole("heading", { name: "Agent Dashboard" }); + const board = page.getByTestId("dashboard-board"); + // The worktree in front names itself in the page's one h1 (App.tsx + // WorkspaceHeading, or the launcher's title once its tabs are closed). + const inWorktree = page.getByRole("heading", { level: 1, name: /search-perf/ }); + const card = board.locator("article", { hasText: "Speed up product search" }); + const inQaDeck = page.getByRole("heading", { level: 1, name: /qa-deck/ }); + const scrollTop = () => board.evaluate((el) => el.scrollTop); + const onSession = async () => { + await expect(inWorktree).toBeAttached(); + await expect(dashboard).toBeHidden(); + }; + + step("a new window is on Home with nowhere to go back or forward to"); + await expect(page.getByRole("group", { name: "History" })).toBeVisible(); + await expect(back).toBeDisabled(); + await expect(forward).toBeDisabled(); + await shot("1-home"); + + step("⌘J opens the Agent Dashboard; scroll it to the bottom"); + await page.keyboard.press("Meta+j"); + await expect(dashboard).toBeVisible(); + await expect(back).toBeEnabled(); + await board.hover(); + await page.mouse.wheel(0, 2000); + // With Labs on the board is only a little taller than the window. + await expect.poll(scrollTop).toBeGreaterThan(0); + const scrolled = await scrollTop(); + await shot("2-dashboard-scrolled"); + + step("click the 'Speed up product search' card: its worktree, search-perf, comes to the front"); + await card.click(); + await onSession(); + await expect(back).toBeEnabled(); + await expect(forward).toBeDisabled(); + await shot("3-session"); + + step("Back returns to the Agent Dashboard, scrolled where it was"); + await back.click(); + await expect(dashboard).toBeVisible(); + await expect.poll(scrollTop).toBe(scrolled); + await expect(forward).toBeEnabled(); + await shot("4-back-to-dashboard"); + + step("Forward returns to the session"); + await forward.click(); + await onSession(); + await expect(forward).toBeDisabled(); + await shot("5-forward-to-session"); + + step("⌘[ goes back to the dashboard and ⌘] forward to the session"); + await page.keyboard.press("Meta+BracketLeft"); + await expect(dashboard).toBeVisible(); + await shot("6-cmd-bracket-back"); + await page.keyboard.press("Meta+BracketRight"); + await onSession(); + await shot("7-cmd-bracket-forward"); + + step("a mouse's back button (3) and forward button (4) do the same"); + const mouse = (button) => page.evaluate((b) => window.dispatchEvent(new MouseEvent("mouseup", { button: b, bubbles: true, cancelable: true })), button); + await mouse(3); + await expect(dashboard).toBeVisible(); + await mouse(4); + await onSession(); + + step("close the session's tab: the worktree's launcher shows"); + await page.keyboard.press("Meta+w"); + const archive = page.getByRole("button", { name: "Archive this worktree…" }); + await expect(archive).toBeVisible(); + await shot("8-launcher"); + + step("archive the worktree from the launcher: you land on the Agent Dashboard, not Home"); + await archive.click(); + const confirm = page.getByRole("alertdialog"); + await expect(confirm).toContainText("Archive"); + await confirm.getByRole("button", { name: "Archive", exact: true }).click(); + // shop's archive script runs first (the mock takes 1.5s), then the box + // says the worktree went. + await expect(page.getByText("Archived search-perf")).toBeVisible({ timeout: 10_000 }); + await expect(dashboard).toBeVisible(); + await expect(inWorktree).toHaveCount(0); + await expect(card).toHaveCount(0); + await shot("9-archived-back-on-dashboard"); + + step("the archived worktree is gone from the trail: Forward has nowhere to go"); + await expect(forward).toBeDisabled(); + + step("open 'Build the QA deck for the release' (qa-deck), then Remove worktree from its sidebar row: back on the dashboard again"); + await board.locator("article", { hasText: "Build the QA deck for the release" }).click(); + await expect(inQaDeck).toBeAttached(); + const row = page.locator('[data-testid=worktree-row][data-worktree="devl/qa-deck"]'); + await row.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Remove worktree", exact: true }).click(); + await expect(row).toHaveCount(0, { timeout: 10_000 }); + await expect(dashboard).toBeVisible(); + await expect(inQaDeck).toHaveCount(0); + await shot("10-removed-back-on-dashboard"); +} diff --git a/app/e2e/back-forward.spec.ts b/app/e2e/back-forward.spec.ts new file mode 100644 index 00000000..cec909bd --- /dev/null +++ b/app/e2e/back-forward.spec.ts @@ -0,0 +1,40 @@ +import { expect, mockOnly, test } from "./fixtures"; + +// Back and forward through the places you've been (lib/nav-history.ts), and +// archiving the worktree in front taking you back to where you were before +// it rather than Home. + +test("Back and Forward walk the places you've been, and archiving the worktree in front goes back", async ({ app }) => { + mockOnly(); + await app.open(); + const page = app.page; + const back = page.getByTestId("history-back"); + const forward = page.getByTestId("history-forward"); + const dashboard = page.getByRole("heading", { name: "Agent Dashboard" }); + const inWorktree = page.getByRole("heading", { level: 1, name: /search-perf/ }); + + await expect(back).toBeDisabled(); + await expect(forward).toBeDisabled(); + await page.keyboard.press("Meta+j"); + await expect(dashboard).toBeVisible(); + await page.getByTestId("dashboard-board").locator("article", { hasText: "Speed up product search" }).click(); + await expect(inWorktree).toBeAttached(); + + await back.click(); + await expect(dashboard).toBeVisible(); + await forward.click(); + await expect(inWorktree).toBeAttached(); + await expect(forward).toBeDisabled(); + await page.keyboard.press("Meta+BracketLeft"); + await expect(dashboard).toBeVisible(); + await page.keyboard.press("Meta+BracketRight"); + await expect(inWorktree).toBeAttached(); + + await page.keyboard.press("Meta+w"); + await page.getByRole("button", { name: "Archive this worktree…" }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Archive", exact: true }).click(); + await expect(page.getByText("Archived search-perf")).toBeVisible(); + await expect(dashboard).toBeVisible(); + await expect(inWorktree).toHaveCount(0); + await expect(forward).toBeDisabled(); +}); diff --git a/app/src/components/app-sidebar.tsx b/app/src/components/app-sidebar.tsx index 4dcb3848..e14eeaae 100644 --- a/app/src/components/app-sidebar.tsx +++ b/app/src/components/app-sidebar.tsx @@ -3,6 +3,7 @@ import { useMemo } from "react"; import { NotificationBell } from "@/components/notifications/notification-center"; import { newSection } from "@/components/sidebar/actions"; +import { BackForward } from "@/components/sidebar/back-forward"; import { MoreItems, Nav as PlacesNav, RowKeys, rowClass, useArrangedNav } from "@/components/sidebar/nav"; import { ProjectsColumn, ShowTasks, TasksColumn, usePicked } from "@/components/sidebar/columns"; import { Projects, useSidebarPrefs } from "@/components/sidebar/projects"; @@ -118,14 +119,15 @@ function ColumnsSidebar({ prefs, update }: SidebarPrefsProps) { } // WindowStrip is the sidebar's top: room for the macOS traffic lights, then -// the notifications and the way to fold the sidebar; the strip drags the -// window. The Linux app has no lights, and says it is an alpha there -// instead. extra goes before the fold (the two-column sidebar's way back -// to its tasks). +// Back and Forward, the notifications and the way to fold the sidebar; the +// strip drags the window. The Linux app has no lights, and says it is an +// alpha there instead. extra goes before the fold (the two-column sidebar's +// way back to its tasks). function WindowStrip({ extra }: { extra?: React.ReactNode }) { return (