feat(app): try session header separators from an Experiments tab (#57568)

This commit is contained in:
Kit Langton
2026-10-20 22:04:36 -07:00
committed by GitHub
Unverified
parent 6a28455553
commit 409d6bb710
5 changed files with 142 additions and 8 deletions
@@ -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);
}
@@ -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: {
<Menu placement="bottom-start" gutter={4} modal={false} onOpenChange={onOpenChange}>
<Menu.Trigger
data-slot="session-location-trigger"
data-separator={props.trailing ? separator() : undefined}
class="flex h-6 min-w-0 max-w-[60%] shrink items-center gap-1.5 rounded-md ps-1 pe-2 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"
>
<ProjectAvatar
@@ -276,13 +296,14 @@ export function SessionLocation(props: {
</Menu.Content>
</Menu.Portal>
</Menu>
<Show when={props.trailing}>
<Show when={props.trailing && separator() !== "chip"}>
<span
data-slot="session-title-separator"
class="shrink-0 pe-1 text-[11px] font-medium text-v2-text-text-faint"
data-variant={separator()}
class="session-location-separator shrink-0 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
{separator() === "slash" ? "/" : undefined}
</span>
</Show>
</>
+8 -1
View File
@@ -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)
}
+21
View File
@@ -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")
})
})
+26 -3
View File
@@ -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 extends string, Value> = {
kind: Kind
@@ -22,6 +22,7 @@ export type MotionKnob =
| (Knob<"spring", DurationOptions> & { presets: Record<MotionPreset, DurationOptions> })
| Knob<"toggle", boolean>
| (Knob<"number", number> & { min: number; max: number; step: number; unit?: string })
| (Knob<"choice", string> & { options: readonly [string, ...string[]]; labels: Record<string, string> })
export type MotionAction = { id: string; label: string; group: string; run: () => void }
@@ -36,7 +37,7 @@ const actions = new Map<string, MotionAction>()
const listeners = new Set<() => void>()
// Saved as numbers only: `<id>.duration` and `<id>.bounce` for springs, 0 or 1 for toggles.
// Saved as numbers only: `<id>.duration` and `<id>.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<const T extends string>(id: string, meta: Meta & { value: NoInfer<T>; labels: Record<T, string> }) {
// 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]]))