From c25077c2e527b2ceee559f572665783dbb318243 Mon Sep 17 00:00:00 2001 From: David Hill Date: Wed, 25 Mar 2026 14:18:29 +0000 Subject: [PATCH] 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. --- packages/app/src/components/pendulum.tsx | 199 ++++++ .../app/src/pages/layout/sidebar-items.tsx | 7 +- packages/app/src/pages/session/helpers.ts | 6 +- .../src/pages/session/message-timeline.tsx | 8 +- .../src/pages/session/session-side-panel.tsx | 602 ++++++++++++++++-- 5 files changed, 763 insertions(+), 59 deletions(-) create mode 100644 packages/app/src/components/pendulum.tsx diff --git a/packages/app/src/components/pendulum.tsx b/packages/app/src/components/pendulum.tsx new file mode 100644 index 0000000000..f2a3df93e1 --- /dev/null +++ b/packages/app/src/components/pendulum.tsx @@ -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() + +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 ( + + + + ) +} + +export function Pendulum(props: Omit[0], "kind">) { + return +} diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 75dada05f0..ba51870719 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -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: { > }> - +
diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 7e2c1ccf7b..829a00a7d8 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -15,6 +15,7 @@ type TabsInput = { normalizeTab: (tab: string) => string review?: Accessor hasReview?: Accessor + fixed?: Accessor } 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 }) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 5fef41a550..5338ddab3d 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -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" }} > - +
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 58c650fcd1..2163faf704 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -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 ( +
+
{props.title}
+ +
+ ) +} + +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 ( +
+
+ + {(char, idx) => { + const offset = () => idx() - state.pos + const active = () => offset() >= 0 && offset() < props.cols + const next = () => (active() ? (frames()[state.idx][offset()] ?? char) : char) + return ( + + {next()} + + ) + }} + +
+
+ ) +} + +const SpinnerConcept = (props: { row: Def; order: JSX.Element; controls: JSX.Element; children: JSX.Element }) => { + return ( +
+
+
{props.children}
+
{props.order}
+
{props.controls}
+
+
+ ) +} + 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>((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) => ( +
+ shift(row.id, -1)} + disabled={idx === 0} + aria-label={`Move ${row.name} up`} + /> + shift(row.id, 1)} + disabled={idx === spinner.order.length - 1} + aria-label={`Move ${row.name} down`} + /> +
+ ) + const controls = (row: Def, idx: number) => ( + + event.stopPropagation()} + onClick={(event: MouseEvent) => event.stopPropagation()} + /> + + +
+ {"kind" in row && ( +
+
Chars
+ +
{tune[row.id].cols}
+ +
+ )} + {"kind" in row && ( +
+
Speed
+ +
{tune[row.id].speed.toFixed(1)}
+ +
+ )} + +
+
+
+
+ ) + + return ( +
+
+
Spinner concepts
+
+ Current session title with adjustable loading treatments. +
+
+ Use the arrows beside each concept to reorder the list. +
+
+
+
+ + {(row, idx) => ( + + {"kind" in row ? ( + "replace" in row ? ( + + ) : "shimmer" in row ? ( + + ) : ( + <> +
+ +
+
{preview(row.name)}
+ + ) + ) : ( + <> +
+ +
+
{preview(row.name)}
+ + )} +
+ )} +
+
+
+
+ ) + } + 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: { }} >
- - - - -
+ +
+ + + { const stop = createFileTabListSync({ el, contextOpen }) @@ -251,6 +738,9 @@ export function SessionSidePanel(props: {
+ +
Spinners
+
-
+ + + {(tab) => { + const path = file.pathFromTab(tab) + return ( +
+ {(p) => } +
+ ) + }} +
+
+ + - - - {props.reviewPanel()} - - + + + {props.reviewPanel()} + + - - -
-
- -
- {language.t("session.files.selectToOpen")} -
+ + {spinnerPanel()} + + + + +
+
+ +
+ {language.t("session.files.selectToOpen")}
+
+
+
+ + + + +
+ +
+
- - - -
- -
-
-
-
- - - {(tab) => } - - - - - {(tab) => { - const path = file.pathFromTab(tab) - return ( -
- {(p) => } -
- ) - }} -
-
- + + {(tab) => } + +