feat(app): add session spinner concept lab

Let the desktop app preview and tune alternate loading treatments for session titles so spinner ideas can be explored in context before replacing the default UI.
spinner-concepts
David Hill 2026-03-25 14:18:29 +00:00
parent 4167e25c7e
commit c25077c2e5
5 changed files with 763 additions and 59 deletions

View File

@ -0,0 +1,199 @@
import type { ComponentProps } from "solid-js"
import { createEffect, createSignal, onCleanup } from "solid-js"
type Kind = "pendulum" | "compress" | "sort"
export type BrailleKind = Kind
const bits = [
[0x01, 0x08],
[0x02, 0x10],
[0x04, 0x20],
[0x40, 0x80],
]
const seeded = (seed: number) => {
let s = seed
return () => {
s = (s * 1664525 + 1013904223) & 0xffffffff
return (s >>> 0) / 0xffffffff
}
}
const pendulum = (cols: number, max = 1) => {
const total = 120
const span = cols * 2
const frames = [] as string[]
for (let t = 0; t < total; t++) {
const codes = Array.from({ length: cols }, () => 0x2800)
const p = t / total
const spread = Math.sin(Math.PI * p) * max
const phase = p * Math.PI * 8
for (let pc = 0; pc < span; pc++) {
const swing = Math.sin(phase + pc * spread)
const center = (1 - swing) * 1.5
for (let row = 0; row < 4; row++) {
if (Math.abs(row - center) >= 0.7) continue
codes[Math.floor(pc / 2)] |= bits[row][pc % 2]
}
}
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
}
return frames
}
const compress = (cols: number) => {
const total = 100
const span = cols * 2
const dots = span * 4
const frames = [] as string[]
const rand = seeded(42)
const weight = Array.from({ length: dots }, () => rand())
for (let t = 0; t < total; t++) {
const codes = Array.from({ length: cols }, () => 0x2800)
const p = t / total
const sieve = Math.max(0.1, 1 - p * 1.2)
const squeeze = Math.min(1, p / 0.85)
const active = Math.max(1, span * (1 - squeeze * 0.95))
for (let pc = 0; pc < span; pc++) {
const map = (pc / span) * active
if (map >= active) continue
const next = Math.round(map)
if (next >= span) continue
const char = Math.floor(next / 2)
const dot = next % 2
for (let row = 0; row < 4; row++) {
if (weight[pc * 4 + row] >= sieve) continue
codes[char] |= bits[row][dot]
}
}
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
}
return frames
}
const sort = (cols: number) => {
const span = cols * 2
const total = 100
const frames = [] as string[]
const rand = seeded(19)
const start = Array.from({ length: span }, () => rand() * 3)
const end = Array.from({ length: span }, (_, i) => (i / Math.max(1, span - 1)) * 3)
for (let t = 0; t < total; t++) {
const codes = Array.from({ length: cols }, () => 0x2800)
const p = t / total
const cursor = p * span * 1.2
for (let pc = 0; pc < span; pc++) {
const char = Math.floor(pc / 2)
const dot = pc % 2
const delta = pc - cursor
let center
if (delta < -3) {
center = end[pc]
} else if (delta < 2) {
const blend = 1 - (delta + 3) / 5
const ease = blend * blend * (3 - 2 * blend)
center = start[pc] + (end[pc] - start[pc]) * ease
if (Math.abs(delta) < 0.8) {
for (let row = 0; row < 4; row++) codes[char] |= bits[row][dot]
continue
}
} else {
center = start[pc] + Math.sin(p * Math.PI * 16 + pc * 2.7) * 0.6 + Math.sin(p * Math.PI * 9 + pc * 1.3) * 0.4
}
center = Math.max(0, Math.min(3, center))
for (let row = 0; row < 4; row++) {
if (Math.abs(row - center) >= 0.7) continue
codes[char] |= bits[row][dot]
}
}
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
}
return frames
}
const build = (kind: Kind, cols: number) => {
if (kind === "compress") return compress(cols)
if (kind === "sort") return sort(cols)
return pendulum(cols)
}
const pace = (kind: Kind) => {
if (kind === "pendulum") return 16
return 40
}
const cache = new Map<string, string[]>()
const get = (kind: Kind, cols: number) => {
const key = `${kind}:${cols}`
const saved = cache.get(key)
if (saved) return saved
const made = build(kind, cols)
cache.set(key, made)
return made
}
export const getBrailleFrames = (kind: Kind, cols: number) => get(kind, cols)
export function Braille(props: {
kind?: Kind
cols?: number
rate?: number
class?: string
classList?: ComponentProps<"span">["classList"]
style?: ComponentProps<"span">["style"]
label?: string
}) {
const kind = () => props.kind ?? "pendulum"
const cols = () => props.cols ?? 2
const rate = () => props.rate ?? 1
const [idx, setIdx] = createSignal(0)
createEffect(() => {
if (typeof window === "undefined") return
const frames = get(kind(), cols())
setIdx(0)
const id = window.setInterval(
() => {
setIdx((idx) => (idx + 1) % frames.length)
},
Math.max(10, Math.round(pace(kind()) / rate())),
)
onCleanup(() => window.clearInterval(id))
})
return (
<span
role="status"
aria-label={props.label ?? "Loading"}
class={props.class}
classList={props.classList}
style={props.style}
>
<span aria-hidden="true">{get(kind(), cols())[idx()]}</span>
</span>
)
}
export function Pendulum(props: Omit<Parameters<typeof Braille>[0], "kind">) {
return <Braille {...props} kind="pendulum" />
}

View File

@ -4,12 +4,12 @@ import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { A, useNavigate, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
import { Pendulum } from "@/components/pendulum"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@ -121,7 +121,10 @@ const SessionRow = (props: {
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
<Pendulum
class="inline-flex w-full items-center justify-center overflow-hidden font-mono text-[9px] leading-none text-current select-none"
cols={3}
/>
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />

View File

@ -15,6 +15,7 @@ type TabsInput = {
normalizeTab: (tab: string) => string
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
fixed?: Accessor<string[]>
}
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
@ -22,6 +23,7 @@ export const getSessionKey = (dir: string | undefined, id: string | undefined) =
export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
const fixed = input.fixed ?? (() => emptyTabs)
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
const openedTabs = createMemo(
() => {
@ -30,7 +32,7 @@ export const createSessionTabs = (input: TabsInput) => {
.tabs()
.all()
.flatMap((tab) => {
if (tab === "context" || tab === "review") return []
if (tab === "context" || tab === "review" || fixed().includes(tab)) return []
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
if (seen.has(value)) return []
seen.add(value)
@ -44,6 +46,7 @@ export const createSessionTabs = (input: TabsInput) => {
const active = input.tabs().active()
if (active === "context") return active
if (active === "review" && review()) return active
if (active && fixed().includes(active)) return active
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
const first = openedTabs()[0]
@ -60,6 +63,7 @@ export const createSessionTabs = (input: TabsInput) => {
const closableTab = createMemo(() => {
const active = activeTab()
if (active === "context") return active
if (fixed().includes(active)) return
if (!openedTabs().includes(active)) return
return active
})

View File

@ -9,7 +9,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { TextField } from "@opencode-ai/ui/text-field"
@ -18,6 +17,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { Pendulum } from "@/components/pendulum"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@ -670,7 +670,11 @@ export function MessageTimeline(props: {
class="transition-opacity duration-200 ease-out"
classList={{ "opacity-0": workingStatus() === "hiding" }}
>
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
<Pendulum
cols={2}
class="inline-flex w-4 items-center justify-center overflow-hidden font-mono text-[9px] leading-none select-none"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
/>
</div>
</Show>
</div>

View File

@ -1,8 +1,10 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
@ -14,6 +16,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { Braille, getBrailleFrames, type BrailleKind } from "@/components/pendulum"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
@ -26,6 +29,291 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
const fixedTabs = ["spinners"]
const defs = [
{ id: "current", name: "Current", note: "Existing square pulse", color: "#FFE865" },
{
id: "pendulum-sweep",
name: "Pendulum Sweep",
note: "6-char wave shimmer gliding left to right",
kind: "pendulum" as const,
color: "#FFE865",
shimmer: true,
cols: 6,
speed: 1.8,
},
{
id: "pendulum",
name: "Pendulum",
note: "Braille wave shimmer",
kind: "pendulum" as const,
color: "#FFE865",
cols: 3,
speed: 1,
},
{
id: "pendulum-glow",
name: "Pendulum Glow",
note: "Slower sweep with a softer highlight",
kind: "pendulum" as const,
color: "#FFE865",
fx: "opacity-55",
shimmer: true,
cols: 6,
speed: 1.2,
},
{
id: "compress-sweep",
name: "Compress Sweep",
note: "6-char shimmer that tightens as it moves",
kind: "compress" as const,
color: "#FFE865",
shimmer: true,
cols: 6,
speed: 1.6,
},
{
id: "compress",
name: "Compress",
note: "Tight sieve collapse",
kind: "compress" as const,
color: "#FFE865",
cols: 3,
speed: 1,
},
{
id: "compress-flash",
name: "Compress Flash",
note: "Faster compression pass for a sharper gleam",
kind: "compress" as const,
color: "#FFE865",
shimmer: true,
cols: 6,
speed: 2.2,
},
{
id: "sort-sweep",
name: "Sort Sweep",
note: "6-char noisy shimmer that settles across the title",
kind: "sort" as const,
color: "#FFE865",
shimmer: true,
cols: 6,
speed: 1.7,
},
{
id: "sort",
name: "Sort",
note: "Noisy settle pass",
kind: "sort" as const,
color: "#FFE865",
cols: 3,
speed: 1,
},
{
id: "sort-spark",
name: "Sort Spark",
note: "Brighter pass with more glitch energy",
kind: "sort" as const,
color: "#FFE865",
shimmer: true,
cols: 6,
speed: 2.4,
},
{
id: "pendulum-replace",
name: "Pendulum Replace",
note: "Braille sweep temporarily replaces title characters",
kind: "pendulum" as const,
color: "#FFE865",
replace: true,
cols: 6,
speed: 1.7,
},
{
id: "compress-replace",
name: "Compress Replace",
note: "Compressed pass swaps letters as it crosses the title",
kind: "compress" as const,
color: "#FFE865",
replace: true,
cols: 6,
speed: 1.8,
},
{
id: "sort-replace",
name: "Sort Replace",
note: "Noisy replacement shimmer that settles as it moves",
kind: "sort" as const,
color: "#FFE865",
replace: true,
cols: 6,
speed: 1.9,
},
{
id: "pendulum-sweep-replace",
name: "Pendulum Sweep Replace",
note: "Wave pass swaps letters as it glides across the title",
kind: "pendulum" as const,
color: "#FFE865",
replace: true,
cols: 6,
speed: 2.1,
},
{
id: "compress-flash-replace",
name: "Compress Flash Replace",
note: "Sharper compressed pass that temporarily rewrites the title",
kind: "compress" as const,
color: "#FFE865",
replace: true,
cols: 6,
speed: 2.3,
},
{
id: "sort-spark-replace",
name: "Sort Spark Replace",
note: "Brighter noisy pass that replaces letters and settles them back",
kind: "sort" as const,
color: "#FFE865",
replace: true,
cols: 6,
speed: 2.4,
},
{
id: "pendulum-glow-replace",
name: "Pendulum Glow Replace",
note: "Softer pendulum pass that swaps title letters in place",
kind: "pendulum" as const,
color: "#FFE865",
replace: true,
cols: 6,
speed: 1.4,
},
{
id: "compress-sweep-replace",
name: "Compress Sweep Replace",
note: "Compression pass that rewrites the title as it crosses",
kind: "compress" as const,
color: "#FFE865",
replace: true,
cols: 6,
speed: 1.9,
},
{
id: "sort-sweep-replace",
name: "Sort Sweep Replace",
note: "Settling sort pass that temporarily replaces each character",
kind: "sort" as const,
color: "#FFE865",
replace: true,
cols: 6,
speed: 1.8,
},
] as const
type Def = (typeof defs)[number]
type DefId = Def["id"]
const defsById = new Map(defs.map((row) => [row.id, row]))
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const SpinnerTitle = (props: {
title: string
kind: BrailleKind
color: string
fx?: string
cols: number
rate: number
}) => {
const [x, setX] = createSignal(-18)
createEffect(() => {
if (typeof window === "undefined") return
setX(-18)
const id = window.setInterval(() => {
setX((x) => (x > 112 ? -18 : x + Math.max(0.5, props.rate)))
}, 32)
onCleanup(() => window.clearInterval(id))
})
return (
<div class="relative min-w-0 flex-1 overflow-hidden py-0.5">
<div class="truncate text-14-medium text-text-strong">{props.title}</div>
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div class="absolute top-1/2 -translate-y-1/2" style={{ left: `calc(${x()}% - 6ch)` }}>
<Braille
kind={props.kind}
cols={props.cols}
rate={props.rate}
class={`inline-flex items-center justify-center overflow-hidden font-mono text-[12px] leading-none font-semibold opacity-80 drop-shadow-[0_0_10px_currentColor] select-none ${props.fx ?? ""}`}
style={{ color: props.color }}
/>
</div>
</div>
</div>
)
}
const ReplaceTitle = (props: { title: string; kind: BrailleKind; color: string; cols: number; rate: number }) => {
const chars = createMemo(() => Array.from(props.title))
const frames = createMemo(() => getBrailleFrames(props.kind, props.cols).map((frame) => Array.from(frame)))
const [state, setState] = createStore({ pos: 0, idx: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ pos: 0, idx: 0 })
const anim = window.setInterval(
() => {
setState("idx", (idx) => (idx + 1) % frames().length)
},
Math.max(16, Math.round(42 / Math.max(0.4, props.rate))),
)
const slide = window.setInterval(
() => {
setState("pos", (pos) => (pos >= chars().length - 1 ? 0 : pos + 1))
},
Math.max(90, Math.round(260 / Math.max(0.4, props.rate))),
)
onCleanup(() => {
window.clearInterval(anim)
window.clearInterval(slide)
})
})
return (
<div class="min-w-0 flex-1 overflow-hidden py-0.5">
<div class="truncate whitespace-nowrap font-mono text-[13px] font-semibold text-text-strong">
<For each={chars()}>
{(char, idx) => {
const offset = () => idx() - state.pos
const active = () => offset() >= 0 && offset() < props.cols
const next = () => (active() ? (frames()[state.idx][offset()] ?? char) : char)
return (
<span classList={{ "text-[12px]": active() }} style={{ color: active() ? props.color : undefined }}>
{next()}
</span>
)
}}
</For>
</div>
</div>
)
}
const SpinnerConcept = (props: { row: Def; order: JSX.Element; controls: JSX.Element; children: JSX.Element }) => {
return (
<div class="rounded-lg border border-border-weaker-base bg-background-stronger px-3 py-3">
<div class="flex min-w-0 items-center gap-3 px-3 py-2">
<div class="flex min-w-0 flex-1 items-center gap-3">{props.children}</div>
<div class="shrink-0">{props.order}</div>
<div class="ml-auto shrink-0">{props.controls}</div>
</div>
</div>
)
}
export function SessionSidePanel(props: {
reviewPanel: () => JSX.Element
activeDiff?: string
@ -47,6 +335,7 @@ export function SessionSidePanel(props: {
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const fixed = () => fixedTabs
const panelWidth = createMemo(() => {
if (!open()) return "0px"
if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
@ -70,6 +359,20 @@ export function SessionSidePanel(props: {
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.noChanges"
})
const title = createMemo(() => info()?.title?.trim() || language.t("command.session.new"))
const [tune, setTune] = createStore(
defs.reduce<Record<string, { cols: number; speed: number; color: string }>>((acc, row) => {
acc[row.id] = {
cols: "kind" in row ? row.cols : 3,
speed: "kind" in row ? row.speed : 1,
color: row.color,
}
return acc
}, {}),
)
const [spinner, setSpinner] = createStore({
order: defs.map((row) => row.id) as DefId[],
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
@ -137,12 +440,194 @@ export function SessionSidePanel(props: {
normalizeTab,
review: reviewTab,
hasReview,
fixed,
})
const contextOpen = tabState.contextOpen
const openedTabs = tabState.openedTabs
const activeTab = tabState.activeTab
const activeFileTab = tabState.activeFileTab
const spinnerPanel = () => {
const rows = createMemo(() => spinner.order.map((id) => defsById.get(id)).filter((row) => row !== undefined))
const preview = (name: string) => `${title()} with ${name}`
const move = (from: DefId, to: DefId) => {
if (from === to) return
const order = [...spinner.order]
const fromIdx = order.indexOf(from)
const toIdx = order.indexOf(to)
if (fromIdx === -1 || toIdx === -1) return
const [row] = order.splice(fromIdx, 1)
order.splice(toIdx, 0, row)
setSpinner("order", order)
}
const shift = (id: DefId, delta: -1 | 1) => {
const idx = spinner.order.indexOf(id)
const next = idx + delta
if (idx === -1 || next < 0 || next >= spinner.order.length) return
const order = [...spinner.order]
const [row] = order.splice(idx, 1)
order.splice(next, 0, row)
setSpinner("order", order)
}
const order = (row: Def, idx: number) => (
<div class="flex items-center gap-1">
<IconButton
icon="arrow-up"
variant="ghost"
class="h-6 w-6"
onClick={() => shift(row.id, -1)}
disabled={idx === 0}
aria-label={`Move ${row.name} up`}
/>
<IconButton
icon="arrow-down-to-line"
variant="ghost"
class="h-6 w-6"
onClick={() => shift(row.id, 1)}
disabled={idx === spinner.order.length - 1}
aria-label={`Move ${row.name} down`}
/>
</div>
)
const controls = (row: Def, idx: number) => (
<DropdownMenu gutter={6} placement="bottom-end" modal={false}>
<DropdownMenu.Trigger
as={IconButton}
icon="sliders"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={`${row.name} settings`}
onPointerDown={(event: PointerEvent) => event.stopPropagation()}
onClick={(event: MouseEvent) => event.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="w-52 p-2">
<div class="flex flex-col gap-2">
{"kind" in row && (
<div class="flex items-center gap-2 rounded-md border border-border-weaker-base px-2 py-1">
<div class="text-11-regular text-text-weaker">Chars</div>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded text-text-weak hover:bg-surface-panel"
onClick={() => setTune(row.id, "cols", (value) => clamp(value - 1, 2, 12))}
aria-label={`Decrease ${row.name} characters`}
>
-
</button>
<div class="w-7 text-center text-11-regular text-text-strong">{tune[row.id].cols}</div>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded text-text-weak hover:bg-surface-panel"
onClick={() => setTune(row.id, "cols", (value) => clamp(value + 1, 2, 12))}
aria-label={`Increase ${row.name} characters`}
>
+
</button>
</div>
)}
{"kind" in row && (
<div class="flex items-center gap-2 rounded-md border border-border-weaker-base px-2 py-1">
<div class="text-11-regular text-text-weaker">Speed</div>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded text-text-weak hover:bg-surface-panel"
onClick={() => setTune(row.id, "speed", (value) => clamp(Number((value - 0.2).toFixed(1)), 0.4, 4))}
aria-label={`Decrease ${row.name} speed`}
>
-
</button>
<div class="w-9 text-center text-11-regular text-text-strong">{tune[row.id].speed.toFixed(1)}</div>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded text-text-weak hover:bg-surface-panel"
onClick={() => setTune(row.id, "speed", (value) => clamp(Number((value + 0.2).toFixed(1)), 0.4, 4))}
aria-label={`Increase ${row.name} speed`}
>
+
</button>
</div>
)}
<label class="flex items-center gap-2 rounded-md border border-border-weaker-base px-2 py-1">
<div class="text-11-regular text-text-weaker">Color</div>
<input
type="color"
value={tune[row.id].color}
class="ml-auto h-5 w-7 cursor-pointer rounded border-none bg-transparent p-0"
onInput={(event) => setTune(row.id, "color", event.currentTarget.value)}
aria-label={`${row.name} color`}
/>
</label>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
return (
<div class="flex h-full min-h-0 flex-col bg-background-base">
<div class="border-b border-border-weaker-base px-4 py-3">
<div class="text-13-medium text-text-strong">Spinner concepts</div>
<div class="mt-1 text-12-regular text-text-weak">
Current session title with adjustable loading treatments.
</div>
<div class="mt-1 text-11-regular text-text-weaker">
Use the arrows beside each concept to reorder the list.
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 py-3">
<div class="flex flex-col gap-2">
<For each={rows()}>
{(row, idx) => (
<SpinnerConcept row={row} order={order(row, idx())} controls={controls(row, idx())}>
{"kind" in row ? (
"replace" in row ? (
<ReplaceTitle
title={preview(row.name)}
kind={row.kind}
cols={tune[row.id].cols}
rate={tune[row.id].speed}
color={tune[row.id].color}
/>
) : "shimmer" in row ? (
<SpinnerTitle
title={preview(row.name)}
kind={row.kind}
cols={tune[row.id].cols}
rate={tune[row.id].speed}
color={tune[row.id].color}
fx={"fx" in row ? row.fx : undefined}
/>
) : (
<>
<div class="flex h-6 w-6 shrink-0 items-center justify-center">
<Braille
kind={row.kind}
cols={tune[row.id].cols}
rate={tune[row.id].speed}
class="inline-flex w-5 items-center justify-center overflow-hidden font-mono text-[11px] leading-none font-semibold select-none"
style={{ color: tune[row.id].color }}
/>
</div>
<div class="min-w-0 truncate text-14-medium text-text-strong">{preview(row.name)}</div>
</>
)
) : (
<>
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
<Spinner class="size-4" style={{ color: tune[row.id].color }} />
</div>
<div class="min-w-0 truncate text-14-medium text-text-strong">{preview(row.name)}</div>
</>
)}
</SpinnerConcept>
)}
</For>
</div>
</div>
</div>
)
}
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
@ -168,11 +653,13 @@ export function SessionSidePanel(props: {
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const from = draggable.id.toString()
const to = droppable.id.toString()
const currentTabs = tabs().all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
const toIndex = getTabReorderIndex(currentTabs, from, to)
if (toIndex === undefined) return
tabs().move(draggable.id.toString(), toIndex)
tabs().move(from, toIndex)
}
const handleDragEnd = () => {
@ -225,16 +712,16 @@ export function SessionSidePanel(props: {
}}
>
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs value={activeTab()} onChange={openTab}>
<div class="sticky top-0 shrink-0 flex">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs.List
ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen })
@ -251,6 +738,9 @@ export function SessionSidePanel(props: {
</div>
</Tabs.Trigger>
</Show>
<Tabs.Trigger value="spinners">
<div>Spinners</div>
</Tabs.Trigger>
<Show when={contextOpen()}>
<Tabs.Trigger
value="context"
@ -301,54 +791,58 @@ export function SessionSidePanel(props: {
</TooltipKeybind>
</div>
</Tabs.List>
</div>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content>
</Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
<Tabs.Content value="spinners" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "spinners"}>{spinnerPanel()}</Show>
</Tabs.Content>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")}
</div>
</div>
</div>
</Show>
</Tabs.Content>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab />
</div>
</Show>
</Tabs.Content>
</Show>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable} keyed>
{(tab) => {
const path = file.pathFromTab(tab)
return (
<div data-component="tabs-drag-preview">
<Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />}
</Show>
</Tabs>
</div>
</div>