feat(app): add responsive session header states (#58467)

This commit is contained in:
usrnk1
2026-10-23 16:23:19 +02:00
committed by GitHub
Unverified
parent 8dacad1144
commit 6aa0d5c203
7 changed files with 373 additions and 118 deletions
@@ -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>()
+100
View File
@@ -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
},
}
}
+17 -10
View File
@@ -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