mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 04:27:20 +03:00
feat(app): add responsive session header states (#58467)
This commit is contained in:
committed by
GitHub
Unverified
parent
8dacad1144
commit
6aa0d5c203
@@ -209,42 +209,119 @@ test.describe("location", () => {
|
||||
sessions: [{ id: "ses_location", title: "Location session", directory: worktree }],
|
||||
}
|
||||
|
||||
test("only collapses location names when the timeline header is narrow", async ({ page }) => {
|
||||
test("collapses the worktree name before the project and only then truncates the title", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await page.clock.install()
|
||||
await openSession(page, {
|
||||
...workspace,
|
||||
sessions: [{ id: "ses_location", title: "Investigate session header layout regression", directory: worktree }],
|
||||
})
|
||||
const header = page.locator("[data-session-title]")
|
||||
const names = header.locator('[data-slot="session-location-project"], [data-slot="session-location-worktree"]')
|
||||
const project = header.locator('[data-slot="session-location-project"]')
|
||||
const worktreeName = header.locator('[data-slot="session-location-worktree"]')
|
||||
const location = header.locator('[data-slot="session-location-trigger"]')
|
||||
const row = header.locator(".session-header-layout")
|
||||
const review = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(names).toHaveText(["Location", "quiet-falcon"])
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
|
||||
await expect(project).toHaveText("Location")
|
||||
await expect(worktreeName).toHaveText("quiet-falcon")
|
||||
|
||||
for (const panel of [false, true, false]) {
|
||||
if (panel || (await review.getAttribute("aria-expanded")) === "true") await review.click()
|
||||
await expect(review).toHaveAttribute("aria-expanded", String(panel))
|
||||
// Keep the viewport fixed: a pane can narrow a session independently of the window.
|
||||
for (const state of [
|
||||
{ width: 1000, project: true, worktree: true, truncated: false },
|
||||
{ width: 680, project: true, worktree: false, truncated: false },
|
||||
{ width: 600, project: false, worktree: false, truncated: false },
|
||||
{ width: 490, project: false, worktree: false, truncated: true },
|
||||
{ width: 680, project: true, worktree: false, truncated: false },
|
||||
{ width: 1000, project: true, worktree: true, truncated: false },
|
||||
]) {
|
||||
await row.evaluate((element, width) => (element.style.inlineSize = `${width}px`), state.width)
|
||||
await expect(project).toBeVisible({ visible: state.project })
|
||||
await expect(worktreeName).toBeVisible({ visible: state.worktree })
|
||||
await expect(header.locator(".session-location-worktree-icon")).toBeVisible()
|
||||
await expect
|
||||
.poll(() => header.getByRole("heading").evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(state.truncated)
|
||||
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeInViewport()
|
||||
await header.getByRole("heading").hover()
|
||||
await location.hover()
|
||||
if (state.project && state.worktree) {
|
||||
// Advance past the shared tooltip delay, so absence is not just its initial waiting period.
|
||||
await page.clock.fastForward(500)
|
||||
await expect(tooltip).toBeHidden()
|
||||
}
|
||||
if (!state.project || !state.worktree) await expect(tooltip).toHaveText("Location / quiet-falcon")
|
||||
await expect
|
||||
.poll(() =>
|
||||
names.evaluateAll((elements) => elements.every((element) => getComputedStyle(element).clipPath === "none")),
|
||||
location.evaluate((element) => {
|
||||
const icon = element.querySelector(".session-location-worktree-icon")?.getBoundingClientRect()
|
||||
const bounds = element.getBoundingClientRect()
|
||||
|
||||
const separator = element
|
||||
.closest(".session-header-location")
|
||||
?.querySelector(':scope > [data-slot="session-title-separator"]')
|
||||
?.getBoundingClientRect()
|
||||
|
||||
const label = element.querySelector('[data-slot="session-location-worktree"]')
|
||||
|
||||
const end = label?.checkVisibility({ checkVisibilityCSS: true })
|
||||
? label.getBoundingClientRect().right
|
||||
: icon?.right
|
||||
|
||||
return !!end && !!separator && Math.abs(bounds.right - end - 6) <= 0.5 && bounds.right <= separator.left
|
||||
}),
|
||||
)
|
||||
.toBe(!panel)
|
||||
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeInViewport()
|
||||
await expect
|
||||
.poll(() => header.getByRole("heading").evaluate((element) => element.scrollWidth <= element.clientWidth))
|
||||
.toBe(true)
|
||||
if (panel) {
|
||||
const separator = header.locator('[data-slot="session-title-separator"]')
|
||||
await expect(separator).toBeVisible()
|
||||
await expect(separator).toHaveText("")
|
||||
await expect(separator).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(header.locator(".session-location-worktree-icon")).toBeHidden()
|
||||
const location = header.locator('[data-slot="session-location-trigger"]')
|
||||
await location.hover()
|
||||
await expect(location).toHaveCSS("padding-inline-start", "6px")
|
||||
await expect(location).toHaveCSS("padding-inline-end", "6px")
|
||||
expect(await location.boundingBox()).toMatchObject({ width: 28, height: 28 })
|
||||
}
|
||||
await header.screenshot({ path: test.info().outputPath(`location-${panel ? "compact" : "expanded"}.png`) })
|
||||
await header.screenshot({ path: test.info().outputPath(`location-${state.width}.png`) })
|
||||
}
|
||||
|
||||
await row.evaluate((element) => element.style.removeProperty("inline-size"))
|
||||
await review.click()
|
||||
await expect(review).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(worktreeName).toBeHidden()
|
||||
await review.click()
|
||||
await expect(worktreeName).toBeVisible()
|
||||
|
||||
// A title change must refit even when the session container itself never resizes.
|
||||
await row.evaluate((element) => (element.style.inlineSize = "600px"))
|
||||
await expect(project).toBeHidden()
|
||||
await header.getByRole("heading").click()
|
||||
await header.getByRole("textbox").fill("Short title")
|
||||
await expect(project).toBeVisible()
|
||||
await expect(worktreeName).toBeVisible()
|
||||
await header.getByRole("textbox").press("Escape")
|
||||
await expect(project).toBeHidden()
|
||||
await expect(location).toHaveAccessibleName("Location / quiet-falcon")
|
||||
await expect(location).not.toHaveAttribute("title")
|
||||
await location.hover()
|
||||
await expect(tooltip).toHaveText("Location / quiet-falcon")
|
||||
await page.screenshot({ path: test.info().outputPath("location-tooltip.png") })
|
||||
await location.click()
|
||||
await expect(page.locator('[data-slot="session-location-card"]')).toBeVisible()
|
||||
await expect(tooltip).toBeHidden()
|
||||
})
|
||||
|
||||
test("collapses to the project avatar when there is no worktree", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await openSession(page, {
|
||||
...workspace,
|
||||
sessions: [{ id: "ses_location", title: "Investigate session header layout regression", directory: root }],
|
||||
})
|
||||
const header = page.locator("[data-session-title]")
|
||||
const location = header.locator('[data-slot="session-location-trigger"]')
|
||||
const project = location.locator('[data-slot="session-location-project"]')
|
||||
const row = header.locator(".session-header-layout")
|
||||
await expect(header.getByRole("button", { name: "More options", exact: true })).toBeVisible()
|
||||
await expect(location.locator(".session-location-worktree-icon")).toHaveCount(0)
|
||||
|
||||
for (const width of [1000, 360, 1000]) {
|
||||
await row.evaluate((element, width) => (element.style.inlineSize = `${width}px`), width)
|
||||
await expect(project).toBeVisible({ visible: width === 1000 })
|
||||
await location.hover()
|
||||
|
||||
if (width === 360) expect(await location.boundingBox()).toMatchObject({ width: 28, height: 28 })
|
||||
await header.screenshot({ path: test.info().outputPath(`project-${width}.png`) })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -170,6 +170,36 @@ test("keeps the active nested session visible in a narrow desktop header", async
|
||||
await expect(page).toHaveURL(sessionHref(parentID))
|
||||
})
|
||||
|
||||
test("restores the project name after closing review in a nested session", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 890, height: 900 })
|
||||
await mockWorkspace(page, {
|
||||
name: "Project with a moderately long display name",
|
||||
directory,
|
||||
sessions: [
|
||||
{ id: parentID, title: "Root session outlining repository-wide change analysis and implementation" },
|
||||
{ id: childID, parentID, title: "Intermediate agent examining architecture and regression coverage" },
|
||||
{ id: grandchildID, parentID: childID, title: "Review repository" },
|
||||
],
|
||||
})
|
||||
await page.goto(sessionHref(grandchildID))
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toBeAttached()
|
||||
const header = page.locator("[data-session-title]")
|
||||
const row = header.locator(".session-header-layout")
|
||||
const project = header.locator('[data-slot="session-location-project"]')
|
||||
const review = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(project).toBeVisible()
|
||||
const width = await row.evaluate((element) => element.getBoundingClientRect().width)
|
||||
await header.screenshot({ path: test.info().outputPath("nested-header-before-review.png") })
|
||||
|
||||
await review.click()
|
||||
await expect(project).toBeHidden()
|
||||
await review.click()
|
||||
await expect.poll(() => row.evaluate((element) => element.getBoundingClientRect().width)).toBe(width)
|
||||
await expect(project).toBeVisible()
|
||||
await expect(header.getByRole("heading", { name: "Review repository", exact: true })).toBeVisible()
|
||||
await header.screenshot({ path: test.info().outputPath("nested-header-restored.png") })
|
||||
})
|
||||
|
||||
test("shows parent lineage while the child timeline loads", async ({ page }) => {
|
||||
await setup(page)
|
||||
const requested = Promise.withResolvers<void>()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
/** Fits the location names before allowing the session title to truncate. */
|
||||
export function createSessionHeaderFit() {
|
||||
const [store, setStore] = createStore({ collapse: 0 })
|
||||
let root!: HTMLDivElement
|
||||
|
||||
onMount(() => {
|
||||
const fit = () => {
|
||||
const location = root.querySelector<HTMLElement>(".session-header-location")
|
||||
const title = root.querySelector<HTMLElement>(".session-header-title")
|
||||
const measure = root.querySelector<HTMLElement>(".session-header-title-measure > span")
|
||||
const trigger = root.querySelector<HTMLElement>('[data-slot="session-location-trigger"]')
|
||||
const project = trigger?.querySelector<HTMLElement>('[data-slot="session-location-project"]')
|
||||
const worktree = trigger?.querySelector<HTMLElement>('[data-slot="session-location-worktree"]')
|
||||
|
||||
if (!location || !title || !measure || !trigger || !project || !root.clientWidth) return
|
||||
|
||||
const gap = Number.parseFloat(getComputedStyle(trigger).columnGap)
|
||||
const projectWidth = project.getBoundingClientRect().width + gap
|
||||
const worktreeWidth = worktree ? worktree.getBoundingClientRect().width + gap : 0
|
||||
|
||||
// Hidden labels retain their intrinsic width, so fitting never depends on a previous truncation.
|
||||
const expanded =
|
||||
location.getBoundingClientRect().width +
|
||||
(store.collapse >= 1 ? worktreeWidth : 0) +
|
||||
(store.collapse >= 2 ? projectWidth : 0)
|
||||
|
||||
// Ancestors can fill the space a hidden name freed. Apply their cap to each reconstructed candidate.
|
||||
const max = getComputedStyle(location).maxInlineSize
|
||||
|
||||
const limit =
|
||||
max === "none"
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Number.parseFloat(max) * (max.endsWith("%") ? root.getBoundingClientRect().width / 100 : 1)
|
||||
|
||||
const children = [...title.children].filter(
|
||||
(element) => !element.matches(".session-header-title-measure") && getComputedStyle(element).display !== "none",
|
||||
)
|
||||
|
||||
const titleWidth = children.reduce(
|
||||
(width, element) => {
|
||||
if (element.matches('[data-slot="session-header-gap"]')) return width
|
||||
|
||||
if (element.matches("h1, input")) return width + measure.getBoundingClientRect().width
|
||||
const style = getComputedStyle(element)
|
||||
|
||||
return (
|
||||
width +
|
||||
element.getBoundingClientRect().width +
|
||||
Number.parseFloat(style.marginInlineStart) +
|
||||
Number.parseFloat(style.marginInlineEnd)
|
||||
)
|
||||
},
|
||||
Math.max(0, children.length - 1) * Number.parseFloat(getComputedStyle(title).columnGap),
|
||||
)
|
||||
|
||||
// Everything outside these two sections is reserved for actions, separators and their spacing.
|
||||
const available = location.getBoundingClientRect().width + title.getBoundingClientRect().width - titleWidth
|
||||
setStore(
|
||||
"collapse",
|
||||
Math.min(expanded, limit) <= available
|
||||
? 0
|
||||
: worktree && Math.min(expanded - worktreeWidth, limit) <= available
|
||||
? 1
|
||||
: 2,
|
||||
)
|
||||
}
|
||||
|
||||
const resize = new ResizeObserver(fit)
|
||||
|
||||
const observe = () => {
|
||||
resize.disconnect()
|
||||
resize.observe(root)
|
||||
root
|
||||
.querySelectorAll(
|
||||
".session-header-location, .session-location-label, .session-header-title > *, .session-header-title-measure > span, .session-header-actions",
|
||||
)
|
||||
.forEach((element) => resize.observe(element))
|
||||
fit()
|
||||
}
|
||||
|
||||
// Conditional controls and the rename field can mount without resizing the container itself.
|
||||
const mutation = new MutationObserver(observe)
|
||||
mutation.observe(root, { childList: true, subtree: true })
|
||||
observe()
|
||||
onCleanup(() => {
|
||||
resize.disconnect()
|
||||
mutation.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
ref: (element: HTMLDivElement) => (root = element),
|
||||
get collapse() {
|
||||
return store.collapse
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -72,25 +72,32 @@ motionTuning.subscribe(() => setTuning(read()))
|
||||
* Sits between breadcrumb segments with `gap` of visible space on both sides. `before` and `after` are the space the
|
||||
* neighbouring segments already leave between their ink and this element, so the margins make up the rest.
|
||||
*/
|
||||
export function SessionHeaderSeparator(props: { before?: number; after?: number }) {
|
||||
export function SessionHeaderSeparator(props: {
|
||||
before?: number
|
||||
after?: number
|
||||
glyph?: typeof tuning.glyph
|
||||
gap?: number
|
||||
}) {
|
||||
const glyph = () => props.glyph ?? tuning.glyph
|
||||
|
||||
// The ink of every glyph fills its box, so the gap is exact; the text beside it keeps ~0.5px of side bearing.
|
||||
const width = () => {
|
||||
if (tuning.glyph === "divider") return tuning.weight
|
||||
if (glyph() === "divider") return tuning.weight
|
||||
|
||||
if (tuning.glyph === "dot") return tuning.size / 4
|
||||
if (glyph() === "dot") return tuning.size / 4
|
||||
|
||||
return tuning.size * 0.45
|
||||
}
|
||||
|
||||
const stroke = () => tuning.weight * (tuning.glyph === "bold" ? 2 : 1)
|
||||
const stroke = () => tuning.weight * (glyph() === "bold" ? 2 : 1)
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
data-glyph={tuning.glyph}
|
||||
data-glyph={glyph()}
|
||||
class="session-header-separator text-v2-text-text-faint"
|
||||
style={{
|
||||
"--separator-gap": `${tuning.gap}px`,
|
||||
"--separator-gap": `${props.gap ?? tuning.gap}px`,
|
||||
"--separator-before": props.before === undefined ? undefined : `${props.before}px`,
|
||||
"--separator-after": props.after === undefined ? undefined : `${props.after}px`,
|
||||
opacity: tuning.opacity,
|
||||
@@ -98,14 +105,14 @@ export function SessionHeaderSeparator(props: { before?: number; after?: number
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Switch>
|
||||
<Match when={tuning.glyph === "divider"}>
|
||||
<Match when={glyph() === "divider"}>
|
||||
<span class="block rounded-full bg-current" style={{ width: `${width()}px`, height: `${tuning.size}px` }} />
|
||||
</Match>
|
||||
<Match when={tuning.glyph === "dot"}>
|
||||
<Match when={glyph() === "dot"}>
|
||||
<span class="block rounded-full bg-current" style={{ width: `${width()}px`, height: `${width()}px` }} />
|
||||
</Match>
|
||||
<Match when={tuning.glyph === "slash"}>
|
||||
<span class="session-header-slash" />
|
||||
<Match when={glyph() === "slash"}>
|
||||
<span class="session-header-slash text-v2-icon-icon-faint" />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<svg
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
}
|
||||
|
||||
.session-header-layout {
|
||||
container: session-header / inline-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 0;
|
||||
inline-size: 100%;
|
||||
block-size: 48px;
|
||||
}
|
||||
@@ -14,51 +13,33 @@
|
||||
.session-header-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
gap: 0;
|
||||
min-inline-size: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-header-location > .session-location-tooltip,
|
||||
.session-header-location [data-slot="session-location-trigger"] {
|
||||
max-inline-size: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-header-location .session-location-label {
|
||||
inline-size: max-content;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The label leaves the hit area, but keeps its intrinsic width available to the fit observer. */
|
||||
.session-header-layout[data-location-collapse="1"] [data-slot="session-location-worktree"],
|
||||
.session-header-layout[data-location-collapse="2"] .session-location-label {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.session-header-location:has([data-slot="session-title-ancestors"]) {
|
||||
max-inline-size: 60%;
|
||||
}
|
||||
|
||||
.session-header-location > [data-slot="session-location-trigger"] {
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
/* Use the header's available width, not the window: opening a side panel also narrows the timeline. */
|
||||
@container session-header (max-width: 640px) {
|
||||
.session-header-location {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-header-location > [data-slot="session-location-trigger"] {
|
||||
gap: 8px;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
|
||||
/* The avatar-only trigger is square like the header's icon buttons, 6px from its avatar on every side, and the row
|
||||
adds a 4px gap. */
|
||||
.session-header-location > .session-header-separator {
|
||||
--separator-before: 10px;
|
||||
}
|
||||
|
||||
.session-header-location > .session-header-separator:dir(rtl) {
|
||||
--separator-before: 9px;
|
||||
}
|
||||
|
||||
/* The collapsed header reads avatar, separator, title; the names stay in the trigger's title and accessible name. */
|
||||
.session-header-location .session-location-worktree-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Keep the names available to assistive tech while the icons leave room for the title. */
|
||||
.session-header-location [data-slot="session-location-project"],
|
||||
.session-header-location [data-slot="session-location-worktree"] {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.session-header-title {
|
||||
@@ -75,6 +56,26 @@
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.session-header-title-measure {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
inline-size: 0;
|
||||
block-size: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-header-title-measure > span {
|
||||
display: block;
|
||||
inline-size: max-content;
|
||||
padding-inline: 4px;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
.session-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -99,9 +100,9 @@
|
||||
/* The original "/" text, as generated content so the separator itself stays empty for assistive tech. */
|
||||
.session-header-slash::before {
|
||||
content: "/";
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: var(--line-height-base);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-compact);
|
||||
/* Cancels the glyph's side bearings so the margins above measure to its ink. */
|
||||
margin-inline: -0.25px -0.34px;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SessionInfo } from "@opencode/client/promise"
|
||||
import type { MountedSession, SessionScreen } from "@opencode/gui-extensions/sdk"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import {
|
||||
displayName,
|
||||
getProjectAvatarGenerated,
|
||||
@@ -10,7 +11,16 @@ import {
|
||||
ProjectAvatar,
|
||||
} from "@opencode/ui/project-avatar"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, For, Show, type ComponentProps, type JSX, type ParentProps } from "solid-js"
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
For,
|
||||
Show,
|
||||
useContext,
|
||||
type ComponentProps,
|
||||
type JSX,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ExtensionSlot } from "@/runtime/extension/render"
|
||||
import { containsDirectory, getFilename } from "@opencode/util/path"
|
||||
@@ -30,10 +40,13 @@ import { sessionHref } from "@/shell/routes/session"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { sessionTitle } from "./title"
|
||||
import { SessionHeaderSeparator } from "./header/separator"
|
||||
import { createSessionHeaderFit } from "./header/fit"
|
||||
import { SessionWorkingIndicator } from "./header/session-working-indicator"
|
||||
import { CollapsedSidebarToggleSpace } from "@/shell/sidebar-toggle"
|
||||
import "./session-identity-header.css"
|
||||
|
||||
const SessionHeaderCollapse = createContext<() => number>(() => 0)
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -53,15 +66,26 @@ export function SessionTitleHeader(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionHeaderLayout(props: ParentProps<{ location: JSX.Element; actions?: JSX.Element }>) {
|
||||
export function SessionHeaderLayout(
|
||||
props: ParentProps<{ title: string | undefined; location: JSX.Element; actions?: JSX.Element }>,
|
||||
) {
|
||||
const fit = createSessionHeaderFit()
|
||||
|
||||
return (
|
||||
<div class="session-header-layout">
|
||||
<div class="session-header-location">{props.location}</div>
|
||||
<div class="session-header-title">{props.children}</div>
|
||||
<Show when={props.actions}>
|
||||
<div class="session-header-actions">{props.actions}</div>
|
||||
</Show>
|
||||
</div>
|
||||
<SessionHeaderCollapse.Provider value={() => fit.collapse}>
|
||||
<div ref={fit.ref} class="session-header-layout" data-location-collapse={fit.collapse}>
|
||||
<div class="session-header-location">{props.location}</div>
|
||||
<div class="session-header-title">
|
||||
<span class="session-header-title-measure" aria-hidden="true">
|
||||
<span>{props.title}</span>
|
||||
</span>
|
||||
{props.children}
|
||||
</div>
|
||||
<Show when={props.actions}>
|
||||
<div class="session-header-actions">{props.actions}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</SessionHeaderCollapse.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,6 +110,7 @@ export function SessionLocation(props: {
|
||||
const layout = useLayout()
|
||||
const settingsSurface = useSettingsSurface()
|
||||
const navigate = useNavigate()
|
||||
const collapse = useContext(SessionHeaderCollapse)
|
||||
const [store, setStore] = createStore({ open: false })
|
||||
|
||||
const projectName = createMemo(() => displayName(props.project ?? { worktree: props.directory ?? "" }))
|
||||
@@ -101,6 +126,8 @@ export function SessionLocation(props: {
|
||||
|
||||
const git = () => !!props.project?.vcs
|
||||
|
||||
const label = createMemo(() => [projectName(), worktree()].filter(Boolean).join(" / "))
|
||||
|
||||
const branch = () =>
|
||||
props.directory ? server.ctx.data.location.vcs.info({ directory: props.directory })?.branch.current : undefined
|
||||
|
||||
@@ -180,33 +207,44 @@ export function SessionLocation(props: {
|
||||
return (
|
||||
<>
|
||||
<Menu placement="bottom-start" gutter={4} modal={false} onOpenChange={onOpenChange}>
|
||||
<Menu.Trigger
|
||||
data-slot="session-location-trigger"
|
||||
title={[projectName(), worktree()].filter(Boolean).join(" / ")}
|
||||
class="flex h-7 min-w-0 max-w-[60%] shrink items-center gap-1.5 rounded-[6px] px-1 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"
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
value={<bdi dir="auto">{label()}</bdi>}
|
||||
class="session-location-tooltip flex min-w-0 max-w-full"
|
||||
forceOpen={collapse() === 0 ? false : undefined}
|
||||
>
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
generated={getProjectAvatarGenerated(props.project)}
|
||||
/>
|
||||
<bdi dir="auto" data-slot="session-location-project" class="min-w-0 truncate">
|
||||
{projectName()}
|
||||
</bdi>
|
||||
<Show when={!props.compact && worktree()}>
|
||||
{(name) => (
|
||||
<>
|
||||
<Icon name="outline-worktree" class="session-location-worktree-icon shrink-0 text-v2-icon-icon-muted" />
|
||||
<bdi dir="auto" data-slot="session-location-worktree" class="min-w-12 shrink-[1000] truncate">
|
||||
{name()}
|
||||
</bdi>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</Menu.Trigger>
|
||||
<Menu.Trigger
|
||||
data-slot="session-location-trigger"
|
||||
aria-label={label()}
|
||||
class="relative flex h-7 min-w-0 max-w-full shrink items-center gap-2 overflow-hidden rounded-[6px] px-1.5 text-start text-[13px] font-normal 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"
|
||||
>
|
||||
<ProjectAvatar
|
||||
class="shrink-0"
|
||||
aria-hidden="true"
|
||||
fallback={projectName()}
|
||||
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
|
||||
variant={getProjectAvatarVariant(props.project?.icon?.color)}
|
||||
generated={getProjectAvatarGenerated(props.project)}
|
||||
/>
|
||||
<bdi dir="auto" data-slot="session-location-project" class="session-location-label min-w-0 truncate">
|
||||
{projectName()}
|
||||
</bdi>
|
||||
<Show when={!props.compact && worktree()}>
|
||||
{(name) => (
|
||||
<>
|
||||
<SessionHeaderSeparator glyph="slash" gap={0} before={0} after={0} />
|
||||
<Icon
|
||||
name="outline-worktree"
|
||||
class="session-location-worktree-icon shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<bdi dir="auto" data-slot="session-location-worktree" class="session-location-label min-w-0 truncate">
|
||||
{name()}
|
||||
</bdi>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</Menu.Trigger>
|
||||
</Tooltip>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
data-slot="session-location-card"
|
||||
@@ -340,7 +378,7 @@ export function SessionLocation(props: {
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={props.trailing}>
|
||||
<SessionHeaderSeparator />
|
||||
<SessionHeaderSeparator glyph="slash" gap={8} before={6} after={4} />
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
@@ -514,6 +552,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
<Show when={title() || parentTitle()}>
|
||||
<SessionTitleHeader>
|
||||
<SessionHeaderLayout
|
||||
title={title()}
|
||||
location={
|
||||
<>
|
||||
<SessionLocation
|
||||
|
||||
@@ -500,6 +500,7 @@ function MessageTimelineView(
|
||||
<Show when={!props.hideHeader}>
|
||||
<SessionTitleHeader>
|
||||
<SessionHeaderLayout
|
||||
title={title.editing ? title.draft : childTitle()}
|
||||
location={
|
||||
<>
|
||||
<SessionLocation
|
||||
|
||||
Reference in New Issue
Block a user