Compare commits

...

3 Commits

Author SHA1 Message Date
David Hill 5c2960a0d8 more concepts 2026-03-26 12:24:19 +00:00
David Hill 431aca1df9 unsure 2026-03-25 14:19:03 +00:00
David Hill c25077c2e5 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.
2026-03-25 14:18:29 +00:00
9 changed files with 1779 additions and 99 deletions
+199
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" />
}
+30 -20
View File
@@ -4,7 +4,6 @@ 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"
@@ -15,6 +14,7 @@ import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { SpinnerLabHeader } from "@/pages/session/spinner-lab"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers"
@@ -115,26 +115,36 @@ const SessionRow = (props: {
props.clearHoverProjectSoon()
}}
>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
<Show
when={props.isWorking()}
fallback={
<>
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</>
}
>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Match when={props.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={props.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={props.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={props.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
<SpinnerLabHeader
title={props.session.title}
tint={props.tint() ?? "var(--icon-interactive-base)"}
class="min-w-0 flex-1"
/>
</Show>
</A>
)
+5 -1
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
})
@@ -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"
@@ -19,6 +18,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SpinnerLabHeader } from "@/pages/session/spinner-lab"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
@@ -657,33 +657,31 @@ export function MessageTimeline(props: {
/>
</Show>
<div class="flex items-center min-w-0 grow-1">
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
width: working() ? "16px" : "0px",
"margin-right": working() ? "8px" : "0px",
}}
aria-hidden="true"
>
<Show when={workingStatus() !== "hidden"}>
<div
class="transition-opacity duration-200 ease-out"
classList={{ "opacity-0": workingStatus() === "hiding" }}
>
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
</div>
</Show>
</div>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
<Show
when={workingStatus() !== "hidden"}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1>
}
>
{titleValue()}
</h1>
<div
class="min-w-0 grow-1 transition-opacity duration-200 ease-out"
classList={{ "opacity-0": workingStatus() === "hiding" }}
>
<SpinnerLabHeader
title={titleValue() ?? ""}
tint={tint() ?? "var(--icon-interactive-base)"}
/>
</div>
</Show>
}
>
<InlineInput
@@ -1,8 +1,11 @@
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 { Icon } from "@opencode-ai/ui/icon"
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 +17,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"
@@ -25,6 +29,650 @@ import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
import { selectSpinnerLab, useSpinnerLab } from "@/pages/session/spinner-lab"
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,
},
{
id: "pendulum-overlay",
name: "Pendulum Overlay",
note: "Wave pass rides directly on top of the title text",
kind: "pendulum" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 1.9,
},
{
id: "compress-overlay",
name: "Compress Overlay",
note: "Compression shimmer glides over the title without replacing it",
kind: "compress" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 2,
},
{
id: "sort-overlay",
name: "Sort Overlay",
note: "Settling sort shimmer passes over the title text",
kind: "sort" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 1.8,
},
{
id: "pendulum-glow-overlay",
name: "Pendulum Glow Overlay",
note: "Softer pendulum pass layered directly over the title",
kind: "pendulum" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 1.4,
},
{
id: "sort-spark-overlay",
name: "Sort Spark Overlay",
note: "Brighter noisy pass that floats over the title text",
kind: "sort" as const,
color: "#FFE865",
overlay: true,
cols: 6,
speed: 2.4,
},
{
id: "pendulum-frame",
name: "Pendulum Frame",
note: "A pendulum spinner sits before the title and fills the rest of the row after it",
kind: "pendulum" as const,
color: "#FFE865",
frame: true,
cols: 6,
speed: 1.8,
},
{
id: "compress-frame",
name: "Compress Frame",
note: "A compression spinner brackets the title with a long animated tail",
kind: "compress" as const,
color: "#FFE865",
frame: true,
cols: 6,
speed: 1.9,
},
{
id: "compress-tail",
name: "Compress Tail",
note: "A continuous compress spinner starts after the title and fills the rest of the row",
kind: "compress" as const,
color: "#FFE865",
trail: true,
cols: 6,
speed: 1.9,
},
{
id: "sort-frame",
name: "Sort Frame",
note: "A noisy sort spinner wraps the title with a giant animated frame",
kind: "sort" as const,
color: "#FFE865",
frame: true,
cols: 6,
speed: 1.8,
},
{
id: "square-wave",
name: "Square Wave",
note: "A 4-row field of tiny squares shimmers behind the entire title row",
color: "#FFE865",
square: true,
speed: 1.8,
size: 2,
gap: 1,
low: 0.08,
high: 0.72,
},
] 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 adjustable = (row: Def) => "kind" in row || "square" in row
const moving = (row: Def) => "shimmer" in row || "replace" in row || "overlay" in row || "square" in row
const trailFrames = (cols: number) => {
let s = 17
const rnd = () => {
s = (s * 1664525 + 1013904223) & 0xffffffff
return (s >>> 0) / 0xffffffff
}
return Array.from({ length: 120 }, () =>
Array.from({ length: cols }, () => {
let mask = 0
for (let bit = 0; bit < 8; bit++) {
if (rnd() > 0.45) mask |= 1 << bit
}
if (!mask) mask = 1 << Math.floor(rnd() * 8)
return String.fromCharCode(0x2800 + mask)
}).join(""),
)
}
const SpinnerTitle = (props: {
title: string
kind: BrailleKind
color: string
fx?: string
cols: number
anim: number
move: 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.move)))
}, 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.anim}
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
anim: number
move: 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.anim))),
)
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.move))),
)
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 OverlayTitle = (props: {
title: string
kind: BrailleKind
color: string
cols: number
anim: number
move: number
}) => {
let root: HTMLDivElement | undefined
let fx: HTMLDivElement | undefined
const [state, setState] = createStore({ pos: 0, max: 0, dark: false })
createEffect(() => {
if (typeof window === "undefined") return
setState({ pos: 0 })
const id = window.setInterval(
() => setState("pos", (pos) => (pos >= state.max ? 0 : Math.min(state.max, pos + 8))),
Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
)
onCleanup(() => window.clearInterval(id))
})
createEffect(() => {
if (typeof window === "undefined") return
if (!root || !fx) return
const sync = () => setState("max", Math.max(0, root!.clientWidth - fx!.clientWidth))
sync()
const observer = new ResizeObserver(sync)
observer.observe(root)
observer.observe(fx)
onCleanup(() => observer.disconnect())
})
createEffect(() => {
if (typeof window === "undefined") return
const query = window.matchMedia("(prefers-color-scheme: dark)")
const sync = () => setState("dark", query.matches)
sync()
query.addEventListener("change", sync)
onCleanup(() => query.removeEventListener("change", sync))
})
return (
<div ref={root} class="relative min-w-0 flex-1 overflow-hidden py-0.5">
<div class="truncate whitespace-nowrap text-14-medium text-text-strong">{props.title}</div>
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div ref={fx} class="absolute top-1/2 -translate-y-1/2" style={{ left: `${state.pos}px` }}>
<Braille
kind={props.kind}
cols={props.cols}
rate={props.anim}
class="inline-flex items-center justify-center overflow-hidden rounded-sm px-0.5 py-2 font-mono text-[12px] leading-none font-semibold select-none"
style={{ color: props.color, "background-color": state.dark ? "#151515" : "#FCFCFC" }}
/>
</div>
</div>
</div>
)
}
const FrameTitle = (props: { title: string; kind: BrailleKind; color: string; cols: number; anim: number }) => {
const head = createMemo(() => getBrailleFrames(props.kind, props.cols))
const tail = createMemo(() => getBrailleFrames(props.kind, 64))
const [state, setState] = createStore({ idx: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ idx: 0 })
const id = window.setInterval(
() => {
setState("idx", (idx) => (idx + 1) % head().length)
},
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
)
onCleanup(() => window.clearInterval(id))
})
const left = createMemo(() => head()[state.idx] ?? "")
const right = createMemo(() => tail()[state.idx] ?? "")
return (
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="shrink-0 font-mono text-[12px] font-semibold leading-none" style={{ color: props.color }}>
{left()}
</div>
<div class="shrink-0 truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-0 flex-1 overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{right()}
</div>
</div>
)
}
const TrailTitle = (props: { title: string; kind: BrailleKind; color: string; cols: number; anim: number }) => {
const tail = createMemo(() => trailFrames(Math.max(24, props.cols * 12)))
const [state, setState] = createStore({ idx: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ idx: 0 })
const id = window.setInterval(
() => {
setState("idx", (idx) => (idx + 1) % tail().length)
},
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
)
onCleanup(() => window.clearInterval(id))
})
return (
<div class="flex w-full min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="min-w-0 max-w-[55%] flex-[0_1_auto] truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-[10ch] basis-0 flex-[1_1_0%] overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{tail()[state.idx] ?? ""}
</div>
</div>
)
}
const SquareWaveTitle = (props: {
title: string
color: string
anim: number
move: number
size: number
gap: number
low: number
high: number
}) => {
const cols = createMemo(() => Math.max(96, Math.ceil(Array.from(props.title).length * 4.5)))
const cells = createMemo(() =>
Array.from({ length: cols() * 4 }, (_, idx) => ({
row: Math.floor(idx / cols()),
col: idx % cols(),
})),
)
const [state, setState] = createStore({ pos: 0, phase: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ pos: 0, phase: 0 })
const anim = window.setInterval(
() => {
setState("phase", (phase) => phase + 0.45)
},
Math.max(16, Math.round(44 / Math.max(0.4, props.anim))),
)
const slide = window.setInterval(
() => {
setState("pos", (pos) => (pos >= cols() + 10 ? 0 : pos + 1))
},
Math.max(40, Math.round(160 / Math.max(0.4, props.move))),
)
onCleanup(() => {
window.clearInterval(anim)
window.clearInterval(slide)
})
})
return (
<div class="relative min-w-0 flex-1 overflow-hidden py-2">
<div
class="pointer-events-none absolute inset-0 grid content-center overflow-hidden"
aria-hidden="true"
style={{
"grid-template-columns": `repeat(${cols()}, ${props.size}px)`,
"grid-auto-rows": `${props.size}px`,
gap: `${props.gap}px`,
}}
>
<For each={cells()}>
{(cell) => {
const opacity = () => {
const wave = (Math.cos((cell.col - state.pos) * 0.32 - state.phase + cell.row * 0.55) + 1) / 2
return props.low + (props.high - props.low) * wave * wave
}
return (
<div
style={{
width: `${props.size}px`,
height: `${props.size}px`,
"background-color": props.color,
opacity: `${opacity()}`,
}}
/>
)
}}
</For>
</div>
<div class="relative z-10 truncate px-2 text-14-medium text-text-strong">
<span class="bg-background-stronger">{props.title}</span>
</div>
</div>
)
}
const SpinnerConcept = (props: {
row: Def
order: JSX.Element
controls: JSX.Element
children: JSX.Element
active: boolean
color: string
onSelect: () => void
}) => {
return (
<div
role="button"
tabIndex={0}
onClick={props.onSelect}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
props.onSelect()
}}
class="w-full rounded-lg border border-border-weaker-base bg-background-stronger px-3 py-3 text-left transition-colors"
classList={{
"border-border-strong-base": props.active,
}}
>
<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>
<Show when={props.active}>
<div class="shrink-0">
<Icon name="check" size="small" style={{ color: props.color }} />
</div>
</Show>
<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
@@ -39,6 +687,7 @@ export function SessionSidePanel(props: {
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const spinnerLab = useSpinnerLab()
const { params, sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
@@ -47,6 +696,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 +720,10 @@ 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 [spinner, setSpinner] = createStore({
order: defs.map((row) => row.id) as DefId[],
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
@@ -137,12 +791,329 @@ 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"
onPointerDown={(event: PointerEvent) => event.stopPropagation()}
onClick={(event: MouseEvent) => {
event.stopPropagation()
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"
onPointerDown={(event: PointerEvent) => event.stopPropagation()}
onClick={(event: MouseEvent) => {
event.stopPropagation()
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 && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Chars</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].cols}</div>
</div>
<input
type="range"
min="2"
max="12"
step="1"
value={spinnerLab.tune[row.id].cols}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "cols", Number(event.currentTarget.value))}
aria-label={`${row.name} characters`}
/>
</label>
)}
{adjustable(row) && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Animate</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].anim.toFixed(1)}</div>
</div>
<input
type="range"
min="0.4"
max="12"
step="0.1"
value={spinnerLab.tune[row.id].anim}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "anim", Number(event.currentTarget.value))}
aria-label={`${row.name} animation speed`}
/>
</label>
)}
{adjustable(row) && moving(row) && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Move</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].move.toFixed(1)}</div>
</div>
<input
type="range"
min="0.4"
max="12"
step="0.1"
value={spinnerLab.tune[row.id].move}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "move", Number(event.currentTarget.value))}
aria-label={`${row.name} movement speed`}
/>
</label>
)}
{"square" in row && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Square</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].size}px</div>
</div>
<input
type="range"
min="1"
max="6"
step="1"
value={spinnerLab.tune[row.id].size}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "size", Number(event.currentTarget.value))}
aria-label={`${row.name} square size`}
/>
</label>
)}
{"square" in row && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Gap</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].gap}px</div>
</div>
<input
type="range"
min="0"
max="4"
step="1"
value={spinnerLab.tune[row.id].gap}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "gap", Number(event.currentTarget.value))}
aria-label={`${row.name} square gap`}
/>
</label>
)}
{"square" in row && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Base</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].low.toFixed(2)}</div>
</div>
<input
type="range"
min="0"
max="0.6"
step="0.01"
value={spinnerLab.tune[row.id].low}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "low", Number(event.currentTarget.value))}
aria-label={`${row.name} base opacity`}
/>
</label>
)}
{"square" in row && (
<label class="flex flex-col gap-1 rounded-md border border-border-weaker-base px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-11-regular text-text-weaker">Peak</div>
<div class="text-11-regular text-text-strong">{spinnerLab.tune[row.id].high.toFixed(2)}</div>
</div>
<input
type="range"
min="0.2"
max="1"
step="0.01"
value={spinnerLab.tune[row.id].high}
class="w-full"
onInput={(event) => spinnerLab.setTune(row.id, "high", Number(event.currentTarget.value))}
aria-label={`${row.name} peak opacity`}
/>
</label>
)}
<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={spinnerLab.tune[row.id].color}
class="ml-auto h-5 w-7 cursor-pointer rounded border-none bg-transparent p-0"
onInput={(event) => spinnerLab.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">
Click a concept to try it in the session header. Use the arrows beside each concept to reorder the list.
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 pt-3 pb-[200px]">
<div class="flex flex-col gap-2">
<For each={rows()}>
{(row, idx) => (
<SpinnerConcept
row={row}
order={order(row, idx())}
controls={controls(row, idx())}
active={spinnerLab.isActive(row.id)}
color={spinnerLab.tune[row.id].color}
onSelect={() => selectSpinnerLab(row.id)}
>
{"square" in row ? (
<SquareWaveTitle
title={preview(row.name)}
color={spinnerLab.tune[row.id].color}
anim={spinnerLab.tune[row.id].anim}
move={spinnerLab.tune[row.id].move}
size={spinnerLab.tune[row.id].size}
gap={spinnerLab.tune[row.id].gap}
low={Math.min(spinnerLab.tune[row.id].low, spinnerLab.tune[row.id].high - 0.05)}
high={Math.max(spinnerLab.tune[row.id].high, spinnerLab.tune[row.id].low + 0.05)}
/>
) : "kind" in row ? (
"replace" in row ? (
<ReplaceTitle
title={preview(row.name)}
kind={row.kind}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
move={spinnerLab.tune[row.id].move}
color={spinnerLab.tune[row.id].color}
/>
) : "trail" in row ? (
<TrailTitle
title={preview(row.name)}
kind={row.kind}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
color={spinnerLab.tune[row.id].color}
/>
) : "frame" in row ? (
<FrameTitle
title={preview(row.name)}
kind={row.kind}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
color={spinnerLab.tune[row.id].color}
/>
) : "overlay" in row ? (
<OverlayTitle
title={preview(row.name)}
kind={row.kind}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
move={spinnerLab.tune[row.id].move}
color={spinnerLab.tune[row.id].color}
/>
) : "shimmer" in row ? (
<SpinnerTitle
title={preview(row.name)}
kind={row.kind}
cols={spinnerLab.tune[row.id].cols}
anim={spinnerLab.tune[row.id].anim}
move={spinnerLab.tune[row.id].move}
color={spinnerLab.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={spinnerLab.tune[row.id].cols}
rate={spinnerLab.tune[row.id].anim}
class="inline-flex w-5 items-center justify-center overflow-hidden font-mono text-[11px] leading-none font-semibold select-none"
style={{ color: spinnerLab.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: spinnerLab.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 +1139,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 +1198,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 +1224,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 +1277,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>
@@ -0,0 +1,484 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Braille, getBrailleFrames, type BrailleKind } from "@/components/pendulum"
export const spinnerLabIds = [
"current",
"pendulum-sweep",
"pendulum",
"pendulum-glow",
"compress-sweep",
"compress",
"compress-flash",
"sort-sweep",
"sort",
"sort-spark",
"pendulum-replace",
"compress-replace",
"sort-replace",
"pendulum-sweep-replace",
"compress-flash-replace",
"sort-spark-replace",
"pendulum-glow-replace",
"compress-sweep-replace",
"sort-sweep-replace",
"pendulum-overlay",
"compress-overlay",
"sort-overlay",
"pendulum-glow-overlay",
"sort-spark-overlay",
"pendulum-frame",
"compress-frame",
"compress-tail",
"sort-frame",
"square-wave",
] as const
export type SpinnerLabId = (typeof spinnerLabIds)[number]
const ids = new Set<string>(spinnerLabIds)
const trailFrames = (cols: number) => {
let s = 17
const rnd = () => {
s = (s * 1664525 + 1013904223) & 0xffffffff
return (s >>> 0) / 0xffffffff
}
return Array.from({ length: 120 }, () =>
Array.from({ length: cols }, () => {
let mask = 0
for (let bit = 0; bit < 8; bit++) {
if (rnd() > 0.45) mask |= 1 << bit
}
if (!mask) mask = 1 << Math.floor(rnd() * 8)
return String.fromCharCode(0x2800 + mask)
}).join(""),
)
}
const parse = (id: SpinnerLabId) => {
const kind: BrailleKind | undefined = id.startsWith("pendulum")
? "pendulum"
: id.startsWith("compress")
? "compress"
: id.startsWith("sort")
? "sort"
: undefined
const mode =
id === "current"
? "current"
: id === "square-wave"
? "square"
: id.endsWith("-tail")
? "trail"
: id.endsWith("-replace")
? "replace"
: id.endsWith("-overlay")
? "overlay"
: id.endsWith("-frame")
? "frame"
: id === "pendulum" || id === "compress" || id === "sort"
? "spin"
: "shimmer"
const anim = id.includes("glow")
? 1.4
: id.includes("flash") || id.includes("spark")
? 2.4
: id.includes("sweep")
? 1.9
: 1.8
const move = mode === "spin" || mode === "current" ? 1 : anim
return {
id,
mode,
kind,
cols: mode === "spin" ? 3 : 6,
anim,
move,
color: "#FFE865",
size: 2,
gap: 1,
low: 0.08,
high: 0.72,
}
}
type SpinnerLabTune = ReturnType<typeof parse>
const defaults = Object.fromEntries(spinnerLabIds.map((id) => [id, parse(id)])) as Record<SpinnerLabId, SpinnerLabTune>
const [lab, setLab] = createStore({ active: "pendulum" as SpinnerLabId, tune: defaults })
const mask = (title: string, fill: string, pos: number) =>
Array.from(title)
.map((char, idx) => {
const off = idx - pos
if (off < 0 || off >= fill.length) return char
return fill[off] ?? char
})
.join("")
const Shimmer = (props: {
title: string
kind: BrailleKind
cols: number
anim: number
move: number
color: string
}) => {
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.move))), 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.anim}
class="inline-flex items-center justify-center overflow-hidden font-mono text-[12px] leading-none font-semibold opacity-80 select-none"
style={{ color: props.color }}
/>
</div>
</div>
</div>
)
}
const Replace = (props: {
title: string
kind: BrailleKind
cols: number
anim: number
move: number
color: string
}) => {
const chars = createMemo(() => Array.from(props.title))
const frames = createMemo(() => getBrailleFrames(props.kind, props.cols))
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.anim))),
)
const move = window.setInterval(
() => setState("pos", (pos) => (pos >= chars().length - 1 ? 0 : pos + 1)),
Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
)
onCleanup(() => {
window.clearInterval(anim)
window.clearInterval(move)
})
})
return (
<div class="min-w-0 truncate whitespace-nowrap font-mono text-[13px] font-semibold text-text-strong">
{mask(props.title, frames()[state.idx] ?? "", state.pos)}
</div>
)
}
const Overlay = (props: {
title: string
kind: BrailleKind
cols: number
anim: number
move: number
color: string
}) => {
let root: HTMLDivElement | undefined
let fx: HTMLDivElement | undefined
const [state, setState] = createStore({ pos: 0, max: 0, dark: false })
createEffect(() => {
if (typeof window === "undefined") return
setState({ pos: 0 })
const id = window.setInterval(
() => setState("pos", (pos) => (pos >= state.max ? 0 : Math.min(state.max, pos + 8))),
Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
)
onCleanup(() => window.clearInterval(id))
})
createEffect(() => {
if (typeof window === "undefined") return
if (!root || !fx) return
const sync = () => setState("max", Math.max(0, root!.clientWidth - fx!.clientWidth))
sync()
const observer = new ResizeObserver(sync)
observer.observe(root)
observer.observe(fx)
onCleanup(() => observer.disconnect())
})
createEffect(() => {
if (typeof window === "undefined") return
const query = window.matchMedia("(prefers-color-scheme: dark)")
const sync = () => setState("dark", query.matches)
sync()
query.addEventListener("change", sync)
onCleanup(() => query.removeEventListener("change", sync))
})
return (
<div ref={root} 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 ref={fx} class="absolute top-1/2 -translate-y-1/2" style={{ left: `${state.pos}px` }}>
<Braille
kind={props.kind}
cols={props.cols}
rate={props.anim}
class="inline-flex items-center justify-center overflow-hidden rounded-sm px-0.5 py-2 font-mono text-[12px] leading-none font-semibold select-none"
style={{ color: props.color, "background-color": state.dark ? "#151515" : "#FCFCFC" }}
/>
</div>
</div>
</div>
)
}
const Frame = (props: { title: string; kind: BrailleKind; cols: number; anim: number; color: string }) => {
const head = createMemo(() => getBrailleFrames(props.kind, props.cols))
const tail = createMemo(() => getBrailleFrames(props.kind, 64))
const [state, setState] = createStore({ idx: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ idx: 0 })
const id = window.setInterval(
() => setState("idx", (idx) => (idx + 1) % head().length),
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
)
onCleanup(() => window.clearInterval(id))
})
return (
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="shrink-0 font-mono text-[12px] font-semibold leading-none" style={{ color: props.color }}>
{head()[state.idx] ?? ""}
</div>
<div class="shrink-0 truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-0 flex-1 overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{tail()[state.idx] ?? ""}
</div>
</div>
)
}
const Trail = (props: { title: string; kind: BrailleKind; cols: number; anim: number; color: string }) => {
const tail = createMemo(() => trailFrames(Math.max(24, props.cols * 12)))
const [state, setState] = createStore({ idx: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ idx: 0 })
const id = window.setInterval(
() => setState("idx", (idx) => (idx + 1) % tail().length),
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
)
onCleanup(() => window.clearInterval(id))
})
return (
<div class="flex w-full min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
<div class="min-w-0 max-w-[55%] flex-[0_1_auto] truncate text-14-medium text-text-strong">{props.title}</div>
<div
class="min-w-[10ch] basis-0 flex-[1_1_0%] overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
style={{ color: props.color }}
>
{tail()[state.idx] ?? ""}
</div>
</div>
)
}
const Square = (props: {
title: string
anim: number
move: number
color: string
size: number
gap: number
low: number
high: number
}) => {
const cols = createMemo(() => Math.max(96, Math.ceil(Array.from(props.title).length * 4.5)))
const cells = createMemo(() =>
Array.from({ length: cols() * 4 }, (_, idx) => ({ row: Math.floor(idx / cols()), col: idx % cols() })),
)
const [state, setState] = createStore({ pos: 0, phase: 0 })
createEffect(() => {
if (typeof window === "undefined") return
setState({ pos: 0, phase: 0 })
const anim = window.setInterval(
() => setState("phase", (phase) => phase + 0.45),
Math.max(16, Math.round(44 / Math.max(0.4, props.anim))),
)
const move = window.setInterval(
() => setState("pos", (pos) => (pos >= cols() + 10 ? 0 : pos + 1)),
Math.max(40, Math.round(160 / Math.max(0.4, props.move))),
)
onCleanup(() => {
window.clearInterval(anim)
window.clearInterval(move)
})
})
return (
<div class="relative min-w-0 flex-1 overflow-hidden py-2">
<div
class="pointer-events-none absolute inset-0 grid content-center overflow-hidden"
aria-hidden="true"
style={{
"grid-template-columns": `repeat(${cols()}, ${props.size}px)`,
"grid-auto-rows": `${props.size}px`,
gap: `${props.gap}px`,
}}
>
<For each={cells()}>
{(cell) => {
const opacity = () => {
const wave = (Math.cos((cell.col - state.pos) * 0.32 - state.phase + cell.row * 0.55) + 1) / 2
return props.low + (props.high - props.low) * wave * wave
}
return (
<div
style={{
width: `${props.size}px`,
height: `${props.size}px`,
"background-color": props.color,
opacity: `${opacity()}`,
}}
/>
)
}}
</For>
</div>
<div class="relative z-10 truncate px-2 text-14-medium text-text-strong">
<span class="bg-background-stronger">{props.title}</span>
</div>
</div>
)
}
export const selectSpinnerLab = (id: string) => {
if (!ids.has(id)) return
setLab("active", id as SpinnerLabId)
}
export const useSpinnerLab = () => ({
active: () => lab.active,
isActive: (id: string) => lab.active === id,
tune: lab.tune,
config: (id: SpinnerLabId) => lab.tune[id],
current: () => lab.tune[lab.active],
setTune: <K extends keyof SpinnerLabTune>(id: SpinnerLabId, key: K, value: SpinnerLabTune[K]) =>
setLab("tune", id, key, value),
})
export function SpinnerLabHeader(props: { title: string; tint?: string; class?: string }) {
const cfg = createMemo(() => lab.tune[lab.active])
const body = createMemo<JSX.Element>(() => {
const cur = cfg()
if (cur.mode === "current") {
return (
<div class="flex min-w-0 items-center gap-2">
<Spinner class="size-4" style={{ color: props.tint ?? cur.color }} />
<div class="min-w-0 truncate text-14-medium text-text-strong">{props.title}</div>
</div>
)
}
if (cur.mode === "spin" && cur.kind) {
return (
<div class="flex min-w-0 items-center gap-2">
<Braille
kind={cur.kind}
cols={cur.cols}
rate={cur.anim}
class="inline-flex w-4 items-center justify-center overflow-hidden font-mono text-[9px] leading-none select-none"
style={{ color: cur.color }}
/>
<div class="min-w-0 truncate text-14-medium text-text-strong">{props.title}</div>
</div>
)
}
if (cur.mode === "shimmer" && cur.kind) {
return (
<Shimmer
title={props.title}
kind={cur.kind}
cols={cur.cols}
anim={cur.anim}
move={cur.move}
color={cur.color}
/>
)
}
if (cur.mode === "replace" && cur.kind) {
return (
<Replace
title={props.title}
kind={cur.kind}
cols={cur.cols}
anim={cur.anim}
move={cur.move}
color={cur.color}
/>
)
}
if (cur.mode === "overlay" && cur.kind) {
return (
<Overlay
title={props.title}
kind={cur.kind}
cols={cur.cols}
anim={cur.anim}
move={cur.move}
color={cur.color}
/>
)
}
if (cur.mode === "trail" && cur.kind) {
return <Trail title={props.title} kind={cur.kind} cols={cur.cols} anim={cur.anim} color={cur.color} />
}
if (cur.mode === "frame" && cur.kind) {
return <Frame title={props.title} kind={cur.kind} cols={cur.cols} anim={cur.anim} color={cur.color} />
}
return (
<Square
title={props.title}
anim={cur.anim}
move={cur.move}
color={cur.color}
size={cur.size}
gap={cur.gap}
low={cur.low}
high={cur.high}
/>
)
})
return <div class={props.class ?? "min-w-0 grow-1 w-full"}>{body()}</div>
}
+1 -1
View File
@@ -133,9 +133,9 @@
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:",
"semver": "^7.6.3",
"solid-js": "catalog:",
+2
View File
@@ -0,0 +1,2 @@
// Auto-generated by build.ts - do not edit
export declare const snapshot: Record<string, unknown>
File diff suppressed because one or more lines are too long