From 1fc33de5270baabfaecc45a65533dfd7bd69272b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 20 Oct 2026 03:16:49 -0400 Subject: [PATCH] feat(app): show project and worktree in the session header The session header's icon-only project button becomes a breadcrumb before the title: the project avatar and name, then the worktree when the session runs in one. Sessions on another server start with that server's name; the current server is never named. Child sessions show only the project before their lineage. The breadcrumb opens a location card with the project, worktree or local repository, branch, server, the full selectable path, Copy path, Reveal (desktop, current server), and Edit project. Narrow screens, which hide the timeline header, show the same breadcrumb in a bar under the session view tabs. --- packages/app/e2e/regression/home.spec.ts | 67 +--- .../app/e2e/regression/session-header.spec.ts | 71 +++- packages/app/e2e/regression/tabs.spec.ts | 3 +- packages/app/src/runtime/i18n/en.ts | 1 + packages/app/src/session/screen.tsx | 26 +- .../src/session/session-identity-header.css | 52 --- .../src/session/session-identity-header.tsx | 348 ++++++++++-------- .../src/session/timeline/message-timeline.tsx | 7 +- 8 files changed, 317 insertions(+), 258 deletions(-) diff --git a/packages/app/e2e/regression/home.spec.ts b/packages/app/e2e/regression/home.spec.ts index 6a8005f3f50..cb5c133a421 100644 --- a/packages/app/e2e/regression/home.spec.ts +++ b/packages/app/e2e/regression/home.spec.ts @@ -98,11 +98,11 @@ const worktree = "C:/OpenCode/Worktrees/project-42/long-folder-name-for-checking-wrapped-worktree-paths/another-long-folder-name" for (const state of [ - { name: "local", directory: fixture.directory, icon: "monitor" }, - { name: "worktree", directory: worktree, icon: "outline-worktree" }, - { name: "unopened worktree", directory: recovery, icon: "outline-worktree", recovery: "unopened" }, + { name: "local", directory: fixture.directory }, + { name: "worktree", directory: worktree, worktree: "another-long-folder-name" }, + { name: "unopened worktree", directory: recovery, worktree: "project-menu-recovery", recovery: "unopened" }, ]) { - test(`the session project menu opens settings and Home for a ${state.name} project`, async ({ page }) => { + test(`the session location card opens settings and Home for a ${state.name} project`, async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }) const workspace = state.directory !== fixture.directory const messages = Promise.withResolvers() @@ -127,10 +127,10 @@ for (const state of [ await page.goto(sessionHref(fixture.targetID)) const header = page.locator("[data-session-title]") - const trigger = header.getByRole("button", { name, exact: true }) - const menu = page.getByRole("menu", { name, exact: true }) + const trigger = header.locator('[data-slot="session-location-trigger"]') + const menu = page.locator('[data-slot="session-location-card"]') const projectItem = menu.getByRole("menuitem", { name, exact: true }) - const pathItem = menu.getByRole("menuitem", { name: state.directory, exact: true }) + const copyItem = menu.getByRole("menuitem", { name: "Copy path", exact: true }) const settings = page.getByTestId("settings-screen") await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle) @@ -140,25 +140,22 @@ for (const state of [ await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible() } - await expect(trigger.locator("use")).toHaveAttribute("href", `#opencode-v2-icon-${state.icon}`) + await expect(trigger.locator('[data-slot="session-location-project"]')).toHaveText(name) + await expect(trigger.locator('[data-slot="session-location-worktree"]')).toHaveText( + state.worktree ? [state.worktree] : [], + ) await trigger.click() - await expect(menu.getByRole("menuitem")).toHaveText([name, state.directory, "Edit project"]) - await expect(pathItem).toBeDisabled() + await expect(menu.getByRole("menuitem")).toHaveText([name, "Copy path", "Edit project"]) + await expect(menu.locator('[data-slot="session-location-path"]')).toHaveText(state.directory) await page.keyboard.press("Escape") await expect(menu).toBeHidden() await expect(trigger).toBeFocused() await trigger.press("ArrowDown") await expect(projectItem).toBeFocused() await page.keyboard.press("ArrowDown") - await expect(pathItem).toBeFocused() - - for (const key of ["Enter", "Space"]) { - await page.keyboard.press(key) - await expect(menu).toBeVisible() - await expect(pathItem).toBeFocused() - } - + await expect(copyItem).toBeFocused() await page.keyboard.press("ArrowDown") + await expect(menu.getByRole("menuitem", { name: "Edit project", exact: true })).toBeFocused() await page.keyboard.press("Enter") await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue(name) await settings.getByRole("button", { name: "Back to projects", exact: true }).click() @@ -172,37 +169,3 @@ for (const state of [ await expect(row(page, fixture.expected.targetTitle)).toBeVisible() }) } - -test("the project menu path arrow has a glyph when the page has an older icon sprite", async ({ page }) => { - await mockStressTimeline(page) - await seed(page, { tabs: [fixture.targetID] }) - // A page cached before the arrow icon shipped carries a sprite without it. - await page.route( - (url) => url.pathname === sessionHref(fixture.targetID), - async (route) => { - const response = await route.fetch() - await route.fulfill({ - response, - body: (await response.text()).replace( - '