diff --git a/packages/app/src/session/session-identity-header.css b/packages/app/src/session/session-identity-header.css
index 0fd9efa4f43..310f22f0eea 100644
--- a/packages/app/src/session/session-identity-header.css
+++ b/packages/app/src/session/session-identity-header.css
@@ -47,3 +47,65 @@
.session-header-fade {
top: 48px;
}
+
+/* Session header separator variants, chosen in the debug bar's Motion workspace under Experiments. */
+.session-location-separator {
+ padding-inline-end: 4px;
+}
+
+/* The slash's ink sits 1.3px nearer the project than the title; this evens the two gaps. */
+.session-location-separator[data-variant="slash"] {
+ padding-inline: 0.5px 3px;
+}
+
+.session-location-separator:not([data-variant="slash"]) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ inline-size: 16px;
+ block-size: 16px;
+}
+
+.session-location-separator:not([data-variant="slash"])::before {
+ content: "";
+}
+
+.session-location-separator[data-variant="chevron"]::before {
+ inline-size: 5px;
+ block-size: 5px;
+ border-block-start: 1.25px solid currentColor;
+ border-inline-end: 1.25px solid currentColor;
+ transform: translateX(-1px) rotate(45deg);
+}
+
+.session-location-separator[data-variant="chevron"]:dir(rtl)::before {
+ transform: translateX(1px) rotate(-45deg);
+}
+
+.session-location-separator[data-variant="caret"]::before {
+ border-block: 3px solid transparent;
+ border-inline-start: 4px solid currentColor;
+}
+
+.session-location-separator[data-variant="dot"]::before {
+ inline-size: 3px;
+ block-size: 3px;
+ border-radius: 50%;
+ background-color: currentColor;
+}
+
+.session-location-separator[data-variant="divider"]::before {
+ inline-size: 1px;
+ block-size: 14px;
+ background-color: var(--v2-border-border-base);
+}
+
+.session-header-location > [data-slot="session-location-trigger"][data-separator="chip"] {
+ margin-inline-end: 6px;
+ background-color: var(--v2-overlay-simple-overlay-hover);
+}
+
+.session-header-location
+ > [data-slot="session-location-trigger"][data-separator="chip"]:is(:hover, :focus-visible, [data-expanded]) {
+ background-color: var(--v2-overlay-simple-overlay-pressed);
+}
diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx
index 5b127d08d31..0a603f8cf1a 100644
--- a/packages/app/src/session/session-identity-header.tsx
+++ b/packages/app/src/session/session-identity-header.tsx
@@ -1,6 +1,7 @@
import type { SessionInfo } from "@opencode/client/promise"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
+import { motionTuning } from "@opencode/ui/motion-tuning"
import {
displayName,
getProjectAvatarSource,
@@ -8,7 +9,7 @@ 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 { createMemo, createSignal, For, Show, type ComponentProps, type JSX, type ParentProps } from "solid-js"
import { containsDirectory, getFilename } from "@opencode/util/path"
import { useServer } from "@/runtime/server/current"
import { ServerConnection, useServers } from "@/runtime/server/registry"
@@ -29,6 +30,24 @@ import { SessionWorkingIndicator } from "./header/session-working-indicator"
import { CollapsedSidebarToggleSpace } from "@/shell/sidebar-toggle"
import "./session-identity-header.css"
+const separatorChoice = motionTuning.choice("sessionHeader.separator", {
+ label: "Session header separator",
+ group: "Experiments",
+ value: "slash",
+ labels: {
+ slash: "Slash",
+ chevron: "Chevron",
+ caret: "Mini caret",
+ dot: "Middle dot",
+ chip: "Project chip",
+ divider: "Divider",
+ },
+})
+
+const [separator, setSeparator] = createSignal(separatorChoice())
+
+motionTuning.subscribe(() => setSeparator(separatorChoice()))
+
export function SessionTitleHeader(props: ParentProps) {
return (
<>
@@ -166,6 +185,7 @@ export function SessionLocation(props: {
-
+
- /
+ {separator() === "slash" ? "/" : undefined}
>
diff --git a/packages/ui/src/motion-tuning-panel.ts b/packages/ui/src/motion-tuning-panel.ts
index 320fdcc21a3..d2a7462ce87 100644
--- a/packages/ui/src/motion-tuning-panel.ts
+++ b/packages/ui/src/motion-tuning-panel.ts
@@ -23,7 +23,7 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri
}
async function start() {
- const { action, defineControls, knob, spring, toggle } = await import("knobs")
+ const { action, choice, defineControls, knob, spring, toggle } = await import("knobs")
const field = (item: MotionKnob) => {
const meta = { label: item.label, group: item.group, tab: item.group === global ? undefined : item.group }
@@ -32,6 +32,8 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri
if (item.kind === "toggle") return toggle(item.initial, meta)
+ if (item.kind === "choice") return choice(item.initial, item.options, { ...meta, labels: item.labels })
+
return knob(item.initial, { ...meta, min: item.min, max: item.max, step: item.step, unit: item.unit ?? "×" })
}
@@ -46,6 +48,11 @@ export function mountMotionPanel(container: HTMLElement, options: { title?: stri
return motionTuning.set(item, value as boolean)
}
+ if (item.kind === "choice") {
+ // SAFETY: a choice knob's field is a choice field.
+ return motionTuning.set(item, value as string)
+ }
+
// SAFETY: a number knob's field is a number field.
motionTuning.set(item, value as number)
}
diff --git a/packages/ui/src/motion-tuning.test.ts b/packages/ui/src/motion-tuning.test.ts
index 893b9951085..aef154401cd 100644
--- a/packages/ui/src/motion-tuning.test.ts
+++ b/packages/ui/src/motion-tuning.test.ts
@@ -45,3 +45,24 @@ describe("motionTuning.spring", () => {
expect(spring().duration).toBeCloseTo(0.8)
})
})
+
+describe("motionTuning.choice", () => {
+ test("starts on its shipped option and switches to another", () => {
+ const choice = motionTuning.choice("test.choice", {
+ label: "Choice",
+ group: "Experiments",
+ value: "b",
+ labels: { a: "A", b: "B", c: "C" },
+ })
+
+ const knob = motionTuning.list().find((item) => item.id === "test.choice")
+
+ if (knob?.kind !== "choice") throw new Error("expected a choice knob")
+
+ expect(choice()).toBe("b")
+ expect(knob.options).toEqual(["a", "b", "c"])
+
+ motionTuning.set(knob, "c")
+ expect(choice()).toBe("c")
+ })
+})
diff --git a/packages/ui/src/motion-tuning.ts b/packages/ui/src/motion-tuning.ts
index e643a52f0fc..24712c52c31 100644
--- a/packages/ui/src/motion-tuning.ts
+++ b/packages/ui/src/motion-tuning.ts
@@ -1,8 +1,8 @@
import type { DurationOptions } from "./motion"
// Live motion settings for developer mode. Each surface registers knobs under its own group with their shipped defaults,
-// plus actions that replay its motion; the Motion workspace (`motion-tuning-panel`) shows a section per group. Edits
-// persist on this device.
+// plus actions that replay its motion; the Motion workspace (`motion-tuning-panel`) shows a section per group. Choices
+// in the "Experiments" group are design variants to try before one ships. Edits persist on this device.
type Knob = {
kind: Kind
@@ -22,6 +22,7 @@ export type MotionKnob =
| (Knob<"spring", DurationOptions> & { presets: Record })
| Knob<"toggle", boolean>
| (Knob<"number", number> & { min: number; max: number; step: number; unit?: string })
+ | (Knob<"choice", string> & { options: readonly [string, ...string[]]; labels: Record })
export type MotionAction = { id: string; label: string; group: string; run: () => void }
@@ -36,7 +37,7 @@ const actions = new Map()
const listeners = new Set<() => void>()
-// Saved as numbers only: `.duration` and `.bounce` for springs, 0 or 1 for toggles.
+// Saved as numbers only: `.duration` and `.bounce` for springs, 0 or 1 for toggles, the option's index for choices.
const saved = load()
export const motionTuning = {
@@ -71,6 +72,25 @@ export const motionTuning = {
return () => knob.value
},
+ /** One of several named variants, such as a design experiment. `labels` maps each option to what the panel shows. */
+ choice(id: string, meta: Meta & { value: NoInfer; labels: Record }) {
+ // SAFETY: a labels record always has at least the initial value's key.
+ const options = Object.keys(meta.labels) as [T, ...T[]]
+
+ const knob = register({
+ kind: "choice",
+ id,
+ label: meta.label,
+ group: meta.group,
+ initial: meta.value,
+ value: options[number(id, options.indexOf(meta.value))] ?? meta.value,
+ options,
+ labels: meta.labels,
+ })
+
+ // SAFETY: the panel only sets one of the knob's options.
+ return () => knob.value as T
+ },
/**
* A tuned number. A `time` of "duration" is seconds, stretched by the global speed and slow motion; "rate" is per
* second, so it shrinks by the same factor.
@@ -194,6 +214,9 @@ function save() {
if (knob.kind === "toggle") return [[knob.id, Number(knob.value), Number(knob.initial)]]
+ if (knob.kind === "choice")
+ return [[knob.id, knob.options.indexOf(knob.value), knob.options.indexOf(knob.initial)]]
+
return [[knob.id, knob.value, knob.initial]]
})
.flatMap(([id, value, initial]) => (value === initial ? [] : [[id, value]]))