mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): try session header separators from an Experiments tab (#57568)
This commit is contained in:
committed by
GitHub
Unverified
parent
6a28455553
commit
409d6bb710
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]]))
|
||||
|
||||
Reference in New Issue
Block a user