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.
This commit is contained in:
Dax Raad
2026-10-20 03:16:49 -04:00
Unverified
parent 6bae1ad12b
commit 1fc33de527
8 changed files with 317 additions and 258 deletions
+15 -52
View File
@@ -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<void>()
@@ -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(
'<div id="root"',
'<svg id="opencode-v2-icon-sprite" width="0" height="0" aria-hidden="true"><symbol id="opencode-v2-icon-monitor" viewBox="0 0 16 16"><path d="M1 1h14v14H1z"/></symbol></svg><div id="root"',
),
})
},
)
await page.goto(sessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
await header.getByRole("button", { name: fixture.project.name, exact: true }).click()
const arrow = page
.getByRole("menu")
.getByRole("menuitem", { name: fixture.directory, exact: true })
.locator('[data-slot="session-project-open-icon"]')
await expect(arrow).toHaveCount(1)
await expect
.poll(() => arrow.locator("svg").evaluate((element: SVGSVGElement) => element.getBBox().width))
.toBeGreaterThan(0)
await expect(page.locator("#opencode-v2-icon-sprite")).toHaveCount(1)
})
@@ -1,9 +1,9 @@
import type { SessionMessageInfo } from "@opencode/client/promise"
import { expect, test, type Page } from "@playwright/test"
import { seed, sessionHref } from "../utils/app"
import { REMOTE_SERVER, seed, sessionHref } from "../utils/app"
import { fixture, mockStressTimeline } from "../utils/session-fixture"
import { expectSessionTitle } from "../utils/waits"
import { mockWorkspace, openSession } from "../utils/workspace"
import { mockRemoteServer, mockWorkspace, openSession } from "../utils/workspace"
const tabs = (page: Page) => page.locator('[data-slot="titlebar-tabs"] a')
@@ -17,7 +17,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await page.goto(sessionHref(fixture.targetID))
const header = page.locator("[data-session-title]")
const more = header.getByRole("button", { name: "More options", exact: true })
const project = header.getByRole("button", { name: fixture.project.name, exact: true })
const project = header.locator('[data-slot="session-location-trigger"]')
const review = page.getByRole("button", { name: "Toggle review", exact: true })
const details = header.getByRole("button", { name: "Session details", exact: true })
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
@@ -50,11 +50,11 @@ for (const direction of ["ltr", "rtl"] as const) {
if (Math.abs(title.y - titleBounds.y) > 0.5 || Math.abs(title.height - titleBounds.height) > 0.5) return false
return direction === "ltr"
? Math.abs(title.x - icon.x - icon.width - 2) <= 0.5 &&
? title.x > icon.x + icon.width &&
Math.abs(menu.x - title.x - title.width - 2) <= 0.5 &&
menu.x + menu.width <= summary.x &&
summary.x + summary.width <= sidebar.x
: Math.abs(icon.x - title.x - title.width - 2) <= 0.5 &&
: icon.x > title.x + title.width &&
Math.abs(title.x - menu.x - menu.width - 2) <= 0.5 &&
sidebar.x + sidebar.width <= summary.x &&
summary.x + summary.width <= menu.x
@@ -136,6 +136,67 @@ for (const direction of ["ltr", "rtl"] as const) {
})
}
test.describe("location", () => {
const root = "C:/OpenCode/Location"
const worktree = "C:/OpenCode/Worktrees/location/quiet-falcon"
const trigger = (page: Page) => page.locator('[data-slot="session-location-trigger"]')
const workspace = {
name: "Location",
directory: root,
project: { sandboxes: [worktree] },
sessions: [{ id: "ses_location", title: "Location session", directory: worktree }],
}
test("names the project and worktree, and the card copies the full path", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"])
await openSession(page, workspace)
await expect(trigger(page).locator('[data-slot="session-location-project"]')).toHaveText("Location")
await expect(trigger(page).locator('[data-slot="session-location-worktree"]')).toHaveText("quiet-falcon")
// The current server is never named in the breadcrumb.
await expect(trigger(page).locator('[data-slot="session-location-server"]')).toHaveCount(0)
await trigger(page).click()
const card = page.locator('[data-slot="session-location-card"]')
await expect(card.locator('[data-slot="session-location-path"]')).toHaveText(worktree)
await card.getByRole("menuitem", { name: "Copy path", exact: true }).click()
await expect(card).toBeHidden()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(worktree)
})
test("names a session's server when it is not the current one", async ({ page }) => {
await mockWorkspace(page, { name: "Location", sessions: [] })
await mockRemoteServer(page, {
name: "apollo",
directory: "/srv/apollo/project",
sessions: [
{
id: "ses_remote",
title: "Remote session",
directory: "/srv/apollo/project",
slug: "ses_remote",
version: "dev",
time: { created: 1, updated: 1 },
},
],
})
await seed(page, { tabs: [{ session: "ses_remote", server: REMOTE_SERVER }] })
await page.goto(sessionHref("ses_remote", REMOTE_SERVER))
await expect(trigger(page).locator('[data-slot="session-location-server"]')).toHaveText("apollo")
await expect(trigger(page).locator('[data-slot="session-location-project"]')).toHaveText("project")
})
test("shows the location bar under the mobile view tabs", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await openSession(page, workspace)
const bar = page.locator('[data-slot="session-location-bar"]')
await expect(bar.locator('[data-slot="session-location-worktree"]')).toHaveText("quiet-falcon")
await bar.locator('[data-slot="session-location-trigger"]').click()
await expect(page.locator('[data-slot="session-location-card"] [data-slot="session-location-path"]')).toHaveText(
worktree,
)
})
})
test.describe("rename", () => {
const heading = (page: Page, name: string) => page.getByRole("heading", { name, exact: true })
// Rename requests (`PATCH /api/session/:id`); the mock stores the new title for later reads.
+2 -1
View File
@@ -272,7 +272,8 @@ for (const row of [
})
await page.goto(sessionHref(a.id))
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), row.direction)
await page.getByRole("button", { name: "Tabs", exact: true }).click()
// The session location bar also names the "Tabs" project, so scope to the titlebar.
await page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Tabs", exact: true }).click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
+1
View File
@@ -601,6 +601,7 @@ export const dict = {
"session.header.reveal.finder": "Reveal in Finder",
"session.header.reveal.fileExplorer": "Reveal in File Explorer",
"session.header.reveal.containingFolder": "Open containing folder",
"session.location.copyPath": "Copy path",
"settings.tab.servers": "Servers",
"settings.extensions.tab.plugins": "Plugins",
+25 -1
View File
@@ -41,7 +41,8 @@ import { createTimelineSearchController } from "./timeline/search-controller"
import { TimelineSearchBar } from "./timeline/search-bar"
import { createTimelineQuote, TimelineQuote } from "./timeline/quote"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { SessionIdentityHeader, SessionLocation } from "./session-identity-header"
import { useServer } from "@/runtime/server/current"
import { SessionReviewToggle } from "./header/session-header-actions"
import { createTimelineCache } from "./timeline/cache"
@@ -73,6 +74,7 @@ function SessionScreenContent(props: {
const language = useLanguage()
const attachment = useExtensionAttachment()
const settings = useSettings()
const server = useServer()
const isDesktop = session.isDesktop
const bottomMobileTabs = () => settings.general.mobileTitlebarPosition() === "bottom"
const sidebar = createPanelSidebar()
@@ -136,6 +138,15 @@ function SessionScreenContent(props: {
const sideVisible = createMemo(() => isDesktop() && screen.side.open())
const location = {
directory: () => session.data.info()?.location.directory,
project: createMemo(() => {
const info = session.data.info()
return info ? server.ctx.projects.forSession(info) : undefined
}),
}
// A close unmounts the side region; switching to a layout whose side is closed keeps it, hidden.
createEffect(
on(
@@ -283,6 +294,19 @@ function SessionScreenContent(props: {
/>
<TimelineQuote controller={quote} />
<Show when={!isDesktop() && !!session.identity.params.id && !bottomMobileTabs()}>{mobileTabs()}</Show>
{/* Narrow screens hide the timeline header, so the session's location gets its own bar. */}
<Show when={!isDesktop() && !!session.identity.params.id && mobileView() === "session"}>
<div
data-slot="session-location-bar"
class="flex h-9 shrink-0 items-center border-b border-v2-border-border-base px-2"
>
<SessionLocation
project={location.project()}
directory={location.directory()}
compact={!!session.data.info()?.parentID}
/>
</div>
</Show>
{/* Surface query errors without suspending session metadata while messages load. */}
<Show when={timeline.resource.error}>
{(error) => {
@@ -1,55 +1,3 @@
/* Informational tooltips must not block menu items. Kobalte sets pointer events inline. */
[data-popper-positioner]:has(.session-project-info-tooltip),
.session-project-info-tooltip {
pointer-events: none !important;
}
.session-options-menu [data-component="menu-v2-item"] {
padding-inline-end: 30px;
}
.session-project-link {
--session-project-fade-direction: to right;
position: relative;
&:dir(rtl) {
--session-project-fade-direction: to left;
}
&[aria-disabled="true"] {
opacity: 0.5;
}
}
.session-project-link-content {
display: flex;
flex: 1;
min-width: 0;
align-items: center;
gap: 8px;
}
.session-project-link-open {
position: absolute;
inset-inline-end: 12px;
inset-block: 0;
display: flex;
align-items: center;
color: var(--v2-icon-icon-muted);
pointer-events: none;
opacity: 0;
}
.session-project-link:is(:hover, [data-highlighted]):not([aria-disabled="true"]) {
.session-project-link-content {
mask-image: linear-gradient(
var(--session-project-fade-direction),
#000 calc(100% - 40px),
transparent calc(100% - 24px)
);
}
.session-project-link-open {
opacity: 1;
}
}
@@ -1,6 +1,5 @@
import type { SessionInfo } from "@opencode/client/promise"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import {
displayName,
@@ -8,13 +7,13 @@ import {
getProjectAvatarVariant,
ProjectAvatar,
} from "@opencode/ui/project-avatar"
import { Tooltip } from "@opencode/ui/tooltip"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useNavigate } from "@solidjs/router"
import { createMemo, For, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { createMemo, For, Show, type ComponentProps, type ParentProps } from "solid-js"
import { containsDirectory, getFilename } from "@opencode/util/path"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { ServerLabel } from "@/servers/registry/label"
import { fileManagerApp } from "@/home/projects/file-manager"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { errorMessage } from "@/shell/layout/helpers"
@@ -22,7 +21,7 @@ import { useLayout, type LocalProject } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettingsSurface } from "@/settings/surface"
import { pathKey } from "@/workspaces/path-key"
import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { isProjectDirectory, workspaceDirectories } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
import { showToast } from "@/shell/notifications/toast"
import { sessionTitle } from "./title"
@@ -49,33 +48,79 @@ export function SessionTitleHeader(props: ParentProps) {
)
}
export function SessionProjectMenu(props: {
/**
* Where the session runs: the server (only when it is not the current one), the project and its worktree. Opens a card
* with the full path and the location's actions.
*/
export function SessionLocation(props: {
project?: Omit<LocalProject, "expanded">
directory?: string
workspace: boolean
/** Child sessions show only the project; their lineage follows. */
compact?: boolean
/** Adds the separator before the title or lineage that follows. */
trailing?: boolean
}) {
const server = useServer()
const servers = useServers()
const language = useLanguage()
const platform = usePlatform()
const layout = useLayout()
const settingsSurface = useSettingsSurface()
const navigate = useNavigate()
const [state, setState] = createStore({
open: false,
projectTruncated: false,
pathTruncated: false,
pathFocused: false,
})
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
const canOpenPath = () =>
platform.platform === "desktop" && !!platform.openPath && server.isLocal && !!props.directory
const worktree = createMemo(() => {
const directory = props.directory
const openPath = () => {
if (!canOpenPath() || !platform.openPath || !props.directory) return
void platform.openPath(props.directory).catch((cause: unknown) =>
if (!props.project || !directory) return
const root = workspaceDirectories(props.project).find((item) => containsDirectory(item, directory))
return root ? getFilename(root) : undefined
})
const remote = () => (servers.current(server.conn) ? undefined : servers.label(server.conn))
const git = () => !!props.project?.vcs
const branch = () =>
props.directory ? server.ctx.data.location.vcs.info({ directory: props.directory })?.branch.current : undefined
// Revealing a path only makes sense on the machine the app runs on.
const canReveal = () =>
platform.platform === "desktop" && !!platform.revealPath && servers.current(server.conn) && !!props.directory
const onOpenChange = (open: boolean) => {
if (!open || !props.directory || !git()) return
void server.ctx.data.location.vcs.sync({ directory: props.directory }).catch(() => undefined)
}
const copyPath = async () => {
const directory = props.directory
if (!directory) return
try {
await (platform.writeClipboardText?.(directory) ?? navigator.clipboard.writeText(directory))
showToast({
variant: "success",
icon: "circle-check",
title: language.t("common.copied"),
description: directory,
})
} catch (cause) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
}
}
const reveal = () => {
const directory = props.directory
if (!canReveal() || !platform.revealPath || !directory) return
void platform.revealPath(directory).catch((cause: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
@@ -83,133 +128,145 @@ export function SessionProjectMenu(props: {
)
}
const openProjectSettings = () => {
const current = props.project
const openProject = () => {
const project = props.project
if (!current) return
settingsSurface.openProject({
server: ServerConnection.key(server.conn),
project: current.worktree,
})
if (!project) return
server.ctx.projects.touch(project.worktree)
layout.home.setSelection({ server: server.key, directory: project.worktree })
navigate("/")
}
const openProjectSettings = () => {
const project = props.project
if (!project) return
settingsSurface.openProject({ server: ServerConnection.key(server.conn), project: project.worktree })
}
return (
<Menu
placement="bottom-start"
gutter={4}
shift={-10}
modal={false}
open={state.open}
onOpenChange={(open) => setState({ open, pathFocused: false })}
>
<Tooltip placement="bottom" value={<bdi>{projectName()}</bdi>} class="flex shrink-0">
<>
<Menu placement="bottom-start" gutter={4} modal={false} onOpenChange={onOpenChange}>
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
aria-label={projectName()}
data-slot="session-project-trigger"
icon={
<span class="text-v2-icon-icon-muted">
<Icon name={props.workspace ? "outline-worktree" : "monitor"} />
</span>
}
/>
</Tooltip>
<Menu.Portal>
<Menu.Content class="w-max max-w-[min(320px,calc(100vw-16px))]" aria-label={projectName()}>
<Tooltip
placement="top"
gutter={2}
disabled={!state.projectTruncated}
class="min-w-0 cursor-default"
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
value={<bdi>{projectName()}</bdi>}
data-slot="session-location-trigger"
class="flex h-6 min-w-0 max-w-[60%] shrink items-center gap-1.5 rounded-md ps-1 pe-2 text-start text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-hover data-[expanded]:text-v2-text-text-base"
>
<Show when={!props.compact && remote()}>
{(name) => (
<>
<span data-slot="session-location-server" class="flex min-w-0 shrink-[10] items-center gap-1">
<Icon name="server" size="small" class="shrink-0" />
<bdi dir="auto" class="min-w-0 truncate">
{name()}
</bdi>
</span>
<Icon name="chevron-right" class="-mx-1 shrink-0 text-v2-icon-icon-faint" />
</>
)}
</Show>
<ProjectAvatar
class="shrink-0"
aria-hidden="true"
fallback={projectName()}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
/>
<bdi dir="auto" data-slot="session-location-project" class="min-w-0 truncate">
{projectName()}
</bdi>
<Show when={!props.compact && worktree()}>
{(name) => (
<>
<Icon name="chevron-right" class="-mx-1 shrink-0 text-v2-icon-icon-faint" />
<bdi dir="auto" data-slot="session-location-worktree" class="min-w-12 shrink-[1000] truncate">
{name()}
</bdi>
</>
)}
</Show>
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
data-slot="session-location-card"
class="w-[320px] max-w-[calc(100vw-16px)]"
aria-label={projectName()}
>
<Menu.Item
class="min-w-0 w-full"
disabled={!props.project}
onSelect={() => {
const project = props.project
<Menu.Item class="min-w-0" disabled={!props.project} onSelect={openProject}>
<ProjectAvatar
class="shrink-0"
aria-hidden="true"
fallback={projectName()}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
/>
<bdi dir="auto" class="min-w-0 truncate font-[530]">
{projectName()}
</bdi>
</Menu.Item>
<Show when={worktree() || git()}>
<SessionLocationDetail icon={worktree() ? "outline-worktree" : "monitor"}>
{worktree() ?? language.t("session.new.workspace.local")}
</SessionLocationDetail>
</Show>
<Show when={branch()}>
{(name) => <SessionLocationDetail icon="branch">{name()}</SessionLocationDetail>}
</Show>
<SessionLocationDetail icon="server">
<ServerLabel server={server.conn} hint />
</SessionLocationDetail>
<Show when={props.directory}>
{(directory) => (
<>
<Menu.Separator />
<div
data-slot="session-location-path"
class="flex min-w-0 gap-2 px-3 py-1.5 text-[12px] leading-4 text-v2-text-text-muted"
>
<Icon name="folder" class="shrink-0 text-v2-icon-icon-muted" />
<bdi dir="ltr" class="min-w-0 select-text break-all">
{directory()}
</bdi>
</div>
<Menu.Item onSelect={() => void copyPath()}>
<Icon name="outline-copy" class="text-v2-icon-icon-muted" />
{language.t("session.location.copyPath")}
</Menu.Item>
<Show when={canReveal()}>
<Menu.Item onSelect={reveal}>
<Icon name="outline-arrow-up-right" class="text-v2-icon-icon-muted" />
{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
</Menu.Item>
</Show>
</>
)}
</Show>
<Menu.Separator />
<Menu.Item disabled={!props.project} onSelect={openProjectSettings}>
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
{language.t("project.settings.title")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={props.trailing}>
<span
data-slot="session-title-separator"
class="shrink-0 ps-1 pe-0.5 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
</>
)
}
if (!project) return
server.ctx.projects.touch(project.worktree)
layout.home.setSelection({ server: server.key, directory: project.worktree })
navigate("/")
}}
>
<span class="session-project-link-content">
<ProjectAvatar
class="shrink-0"
aria-hidden="true"
fallback={projectName()}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
/>
<bdi
ref={(element) =>
createResizeObserver(element, () =>
setState("projectTruncated", element.scrollWidth > element.clientWidth),
)
}
class="min-w-0 truncate text-13-medium"
>
{projectName()}
</bdi>
</span>
</Menu.Item>
</Tooltip>
<Tooltip
placement="top"
gutter={2}
disabled={!state.pathTruncated}
forceOpen={state.pathFocused && state.pathTruncated ? true : undefined}
class="min-w-0 cursor-default"
contentClass="session-project-info-tooltip max-w-[min(480px,calc(100vw-16px))] whitespace-normal break-all"
value={<bdi dir="ltr">{props.directory}</bdi>}
>
{/* Read-only paths stay in keyboard navigation so their full tooltip remains accessible. */}
<Menu.Item
class="session-project-link min-w-0 w-full cursor-default"
disabled={!props.directory}
aria-disabled={!canOpenPath()}
closeOnSelect={canOpenPath()}
onSelect={openPath}
onFocus={() => setState("pathFocused", true)}
onBlur={() => setState("pathFocused", false)}
onKeyDown={(event) => {
if (event.key !== "Escape") return
event.preventDefault()
event.stopPropagation()
setState({ open: false, pathFocused: false })
}}
>
<span class="session-project-link-content">
<Icon name="folder" class="shrink-0 text-v2-icon-icon-muted" />
<bdi
ref={(element) =>
createResizeObserver(element, () =>
setState("pathTruncated", element.scrollWidth > element.clientWidth),
)
}
dir="ltr"
class="min-w-0 truncate text-v2-text-text-muted"
>
{props.directory}
</bdi>
</span>
<span data-slot="session-project-open-icon" class="session-project-link-open" aria-hidden="true">
<Icon name="arrow-up-right" />
</span>
</Menu.Item>
</Tooltip>
<Menu.Separator />
<Menu.Item disabled={!props.project} onSelect={openProjectSettings}>
<Icon name="settings-gear" class="text-v2-icon-icon-muted" />
{language.t("project.settings.title")}
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
function SessionLocationDetail(props: ParentProps<{ icon: ComponentProps<typeof Icon>["name"] }>) {
return (
<div class="flex h-7 min-w-0 items-center gap-2 px-3 text-[13px] font-[440] leading-[var(--line-height-compact)] tracking-[-0.04px] text-v2-text-text-base">
<Icon name={props.icon} class="shrink-0 text-v2-icon-icon-muted" />
<span class="flex min-w-0 truncate">{props.children}</span>
</div>
)
}
@@ -380,15 +437,18 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
)
})
const workspaceSession = createMemo(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
return (
<Show when={title() || parentTitle()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
<SessionProjectMenu project={project()} directory={directory()} workspace={workspaceSession()} />
<SessionLocation
project={project()}
directory={directory()}
compact={!!parentID()}
trailing={!!(title() || parentID())}
/>
<Show when={parentID()}>
{(id) => (
<SessionAncestorTrail
@@ -25,7 +25,7 @@ import { isWorkspaceDirectory } from "@/workspaces/paths"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { parseVoiceDelegation } from "./voice-delegation"
import { useCommand } from "@/shell/commands/command"
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionAncestorTrail, SessionLocation, SessionTitleHeader } from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import { SessionWorkingIndicator } from "@/session/header/session-working-indicator"
@@ -454,10 +454,11 @@ function MessageTimelineView(
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
<SessionProjectMenu
<SessionLocation
project={headerProject()}
directory={sessionDirectory()}
workspace={workspaceSession()}
compact={!!parentID()}
trailing={!!(parentID() || childTitle() || title.editing)}
/>
<Show when={parentID()}>
{(id) => (