Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 6 additions & 15 deletions packages/app/e2e/regression/session-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,28 +229,19 @@ test.describe("location", () => {
header.evaluate((element) => {
const icon = element.querySelector('svg:has(use[href="#opencode-v2-icon-outline-worktree"])')
const before = icon?.previousElementSibling?.getBoundingClientRect()
const separator = element.querySelector('[data-slot="session-title-separator"]')
const after = separator?.getBoundingClientRect()
const title = element.querySelector("h1")
const avatar = element
.querySelector('[data-slot="session-location-trigger"]')
?.firstElementChild?.getBoundingClientRect()
const bounds = icon?.getBoundingClientRect()

return bounds && before && after && avatar && separator && title
? [
before.left - avatar.right,
bounds.left - before.right,
after.left - bounds.right,
title.getBoundingClientRect().left +
parseFloat(getComputedStyle(title).paddingLeft) -
after.right +
parseFloat(getComputedStyle(separator).paddingRight),
]
: []
return bounds && before && avatar ? [before.left - avatar.right, bounds.left - before.right] : []
}),
)
.toEqual([10, 8, 8, 10])
.toEqual([10, 8])
const separator = header.locator('[data-slot="session-title-separator"]')
await expect(separator).toBeVisible()
await expect(separator).toHaveText("")
await expect(separator).toHaveAttribute("aria-hidden", "true")
const location = header.locator('[data-slot="session-location-trigger"]')
await location.hover()
await expect(location).toHaveCSS("padding-inline-start", "4px")
Expand Down
127 changes: 127 additions & 0 deletions packages/app/src/session/header/separator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { motionTuning } from "@opencode/ui/motion-tuning"
import { Match, Switch } from "solid-js"
import { createStore } from "solid-js/store"

const group = "Experiments"

const glyph = motionTuning.choice("sessionHeader.separator", {
label: "Header separator",
group,
value: "divider",
labels: { divider: "Divider", slash: "Slash", chevron: "Chevron", bold: "Bold chevron", dot: "Dot" },
})

const size = motionTuning.number("sessionHeader.separator.size", {
label: "Separator size",
group,
value: 14,
min: 6,
max: 24,
step: 1,
unit: "px",
})

const weight = motionTuning.number("sessionHeader.separator.weight", {
label: "Separator weight",
group,
value: 1,
min: 0.5,
max: 3,
step: 0.25,
unit: "px",
})

const opacity = motionTuning.number("sessionHeader.separator.opacity", {
label: "Separator opacity",
group,
value: 1,
min: 0.2,
max: 1,
step: 0.05,
})

const gap = motionTuning.number("sessionHeader.separator.gap", {
label: "Separator gap (ink to ink)",
group,
value: 10,
min: 4,
max: 20,
step: 0.5,
unit: "px",
})

const read = () => ({ glyph: glyph(), size: size(), weight: weight(), opacity: opacity(), gap: gap() })

const [tuning, setTuning] = createStore(read())

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 }) {
// 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 (tuning.glyph === "dot") return tuning.size / 4

return tuning.size * 0.45
}

const stroke = () => tuning.weight * (tuning.glyph === "bold" ? 2 : 1)

return (
<span
data-slot="session-title-separator"
data-glyph={tuning.glyph}
class="session-header-separator text-v2-text-text-faint"
style={{
"--separator-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,
}}
aria-hidden="true"
>
<Switch>
<Match when={tuning.glyph === "divider"}>
<span class="block rounded-full bg-current" style={{ width: `${width()}px`, height: `${tuning.size}px` }} />
</Match>
<Match when={tuning.glyph === "dot"}>
<span class="block rounded-full bg-current" style={{ width: `${width()}px`, height: `${width()}px` }} />
</Match>
<Match when={tuning.glyph === "slash"}>
<svg
class="block fill-none stroke-current"
width={tuning.size * 0.4}
height={tuning.size}
viewBox={`0 0 ${tuning.size * 0.4} ${tuning.size}`}
stroke-width={tuning.weight}
stroke-linecap="round"
>
<path
d={`M${tuning.weight / 2} ${tuning.size - tuning.weight / 2}L${tuning.size * 0.4 - tuning.weight / 2} ${tuning.weight / 2}`}
/>
</svg>
</Match>
<Match when={true}>
<svg
class="block fill-none stroke-current"
width={width()}
height={tuning.size}
viewBox={`0 0 ${width()} ${tuning.size}`}
stroke-width={stroke()}
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d={`M${stroke() / 2} ${stroke() / 2}L${width() - stroke() / 2} ${tuning.size / 2}L${stroke() / 2} ${tuning.size - stroke() / 2}`}
/>
</svg>
</Match>
</Switch>
</span>
)
}
29 changes: 21 additions & 8 deletions packages/app/src/session/session-identity-header.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,17 @@
padding-inline: 0;
}

.session-header-location > .session-location-separator[data-variant="slash"] {
/* Together with the row's 2px gap and the title's 4px padding, leave 10px before the title. */
padding-inline: 0 4px;
/* The icon-only trigger sits 4px from its icon and the row adds a 4px gap; the avatar's 2px margin counts too. */
.session-header-location > .session-header-separator {
--separator-before: 10px;
}

.session-header-location > .session-header-separator:dir(rtl) {
--separator-before: 9px;
}

.session-header-location:has([data-slot="session-location-worktree"]) > .session-header-separator {
--separator-before: 9px;
}

/* Keep the names available to assistive tech while the icons leave room for the title. */
Expand Down Expand Up @@ -85,11 +93,16 @@
top: 48px;
}

/* The "/" does not mirror and its side bearings differ, so each direction gets its own padding to centre the ink between the segments. */
.session-location-separator {
padding-inline: 3.5px 3px;
.session-header-separator {
display: flex;
flex-shrink: 0;
align-items: center;
/* The neighbouring segments' own padding counts toward the gap; 0.5px is the text's side bearing. */
margin-inline: calc(var(--separator-gap) - var(--separator-before, 6px) - 0.5px)
calc(var(--separator-gap) - var(--separator-after, 6px) - 0.5px);
}

.session-location-separator:dir(rtl) {
padding-inline: 3.25px 3.5px;
.session-header-separator[data-glyph="chevron"]:dir(rtl) > svg,
.session-header-separator[data-glyph="bold"]:dir(rtl) > svg {
transform: scaleX(-1);
}
27 changes: 5 additions & 22 deletions packages/app/src/session/session-identity-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { isProjectDirectory, workspaceDirectories } from "@/workspaces/paths"
import { sessionHref } from "@/shell/routes/session"
import { showToast } from "@/shell/notifications/toast"
import { sessionTitle } from "./title"
import { SessionHeaderSeparator } from "./header/separator"
import { SessionWorkingIndicator } from "./header/session-working-indicator"
import { CollapsedSidebarToggleSpace } from "@/shell/sidebar-toggle"
import "./session-identity-header.css"
Expand Down Expand Up @@ -341,13 +342,7 @@ export function SessionLocation(props: {
</Menu.Portal>
</Menu>
<Show when={props.trailing}>
<span
data-slot="session-title-separator"
class="session-location-separator shrink-0 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
<SessionHeaderSeparator />
</Show>
</>
)
Expand Down Expand Up @@ -408,32 +403,20 @@ export function SessionAncestorTrail(props: {
data-session-id={ancestor.id}
title={ancestor.title}
class="max-w-[min(200px,40vw)] shrink-0 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
classList={{ "ps-1": index() === 0, "ps-2": index() > 0 }}
classList={{ "ps-1": index() === 0 }}
onClick={() => open(props.sessionID, ancestor.id)}
>
<bdi dir="auto">{ancestor.title}</bdi>
</button>
<Show when={index() < ancestors().length - 1}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] shrink-0 ps-2 pe-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
<SessionHeaderSeparator before={0} after={0} />
</Show>
</>
)}
</For>
</div>
<Show when={props.trailing}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] shrink-0 ps-2 pe-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
<SessionHeaderSeparator before={0} />
</Show>
</div>
)
Expand Down
Loading