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
parent
4167e25c7e
commit
c25077c2e5
|
|
@ -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" />
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue