mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
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:
@@ -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.
|
||||
|
||||
@@ -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)}"])`)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user