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: { - + 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]]))