diff --git a/packages/app/e2e/regression/session-header.spec.ts b/packages/app/e2e/regression/session-header.spec.ts
index fda461702fd6..73c987221b51 100644
--- a/packages/app/e2e/regression/session-header.spec.ts
+++ b/packages/app/e2e/regression/session-header.spec.ts
@@ -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")
diff --git a/packages/app/src/session/header/separator.tsx b/packages/app/src/session/header/separator.tsx
new file mode 100644
index 000000000000..0fc0aa77439c
--- /dev/null
+++ b/packages/app/src/session/header/separator.tsx
@@ -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 (
+
+ )
+}
diff --git a/packages/app/src/session/session-identity-header.css b/packages/app/src/session/session-identity-header.css
index 3f00df15df09..22232567e22c 100644
--- a/packages/app/src/session/session-identity-header.css
+++ b/packages/app/src/session/session-identity-header.css
@@ -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. */
@@ -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);
}
diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx
index ec7f7cc752f9..c479c5131c10 100644
--- a/packages/app/src/session/session-identity-header.tsx
+++ b/packages/app/src/session/session-identity-header.tsx
@@ -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"
@@ -341,13 +342,7 @@ export function SessionLocation(props: {
-
- /
-
+
>
)
@@ -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)}
>
{ancestor.title}
-
- /
-
+
>
)}
-
- /
-
+
)