diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index ba51870719..afc0413dbf 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -9,12 +9,12 @@ 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" 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,29 +115,36 @@ const SessionRow = (props: { props.clearHoverProjectSoon() }} > -
+
+ }> + +
+ + +
+ + 0}> +
+ + +
+ {props.session.title} + + } > - }> - - - - -
- - -
- - 0}> -
- - -
- {props.session.title} + + ) diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 5338ddab3d..afcd19614b 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -17,8 +17,8 @@ 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 { 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,37 +657,31 @@ export function MessageTimeline(props: { />
- + {titleValue()} + + } > - {titleValue()} - +
+ +
+
} > [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 @@ -225,7 +348,8 @@ const SpinnerTitle = (props: { color: string fx?: string cols: number - rate: number + anim: number + move: number }) => { const [x, setX] = createSignal(-18) @@ -233,7 +357,7 @@ const SpinnerTitle = (props: { if (typeof window === "undefined") return setX(-18) const id = window.setInterval(() => { - setX((x) => (x > 112 ? -18 : x + Math.max(0.5, props.rate))) + setX((x) => (x > 112 ? -18 : x + Math.max(0.5, props.move))) }, 32) onCleanup(() => window.clearInterval(id)) }) @@ -246,7 +370,7 @@ const SpinnerTitle = (props: { @@ -256,7 +380,14 @@ const SpinnerTitle = (props: { ) } -const ReplaceTitle = (props: { title: string; kind: BrailleKind; color: string; cols: number; rate: number }) => { +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 }) @@ -268,13 +399,13 @@ const ReplaceTitle = (props: { title: string; kind: BrailleKind; color: string; () => { setState("idx", (idx) => (idx + 1) % frames().length) }, - Math.max(16, Math.round(42 / Math.max(0.4, props.rate))), + 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.rate))), + Math.max(90, Math.round(260 / Math.max(0.4, props.move))), ) onCleanup(() => { window.clearInterval(anim) @@ -302,11 +433,240 @@ const ReplaceTitle = (props: { title: string; kind: BrailleKind; color: string; ) } -const SpinnerConcept = (props: { row: Def; order: JSX.Element; controls: JSX.Element; children: JSX.Element }) => { +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 ( -
+
+
{props.title}
+ +
+ ) +} + +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 ( +
+
+ {left()} +
+
{props.title}
+
+ {right()} +
+
+ ) +} + +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 ( +
+
{props.title}
+
+ {tail()[state.idx] ?? ""} +
+
+ ) +} + +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 ( +
+ + ) +} + +const SpinnerConcept = (props: { + row: Def + order: JSX.Element + controls: JSX.Element + children: JSX.Element + active: boolean + color: string + onSelect: () => void +}) => { + return ( +
{ + 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, + }} + >
{props.children}
+ +
+ +
+
{props.order}
{props.controls}
@@ -327,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)") @@ -360,16 +721,6 @@ export function SessionSidePanel(props: { 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[], }) @@ -475,7 +826,11 @@ export function SessionSidePanel(props: { icon="arrow-up" variant="ghost" class="h-6 w-6" - onClick={() => shift(row.id, -1)} + onPointerDown={(event: PointerEvent) => event.stopPropagation()} + onClick={(event: MouseEvent) => { + event.stopPropagation() + shift(row.id, -1) + }} disabled={idx === 0} aria-label={`Move ${row.name} up`} /> @@ -483,7 +838,11 @@ export function SessionSidePanel(props: { icon="arrow-down-to-line" variant="ghost" class="h-6 w-6" - onClick={() => shift(row.id, 1)} + 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`} /> @@ -504,56 +863,138 @@ export function SessionSidePanel(props: {
{"kind" in row && ( -
-
Chars
- -
{tune[row.id].cols}
- -
+ )} - {"kind" in row && ( -
-
Speed
- -
{tune[row.id].speed.toFixed(1)}
- -
+ {adjustable(row) && ( + + )} + {adjustable(row) && moving(row) && ( + + )} + {"square" in row && ( + + )} + {"square" in row && ( + + )} + {"square" in row && ( + + )} + {"square" in row && ( + )} @@ -571,30 +1012,75 @@ export function SessionSidePanel(props: { Current session title with adjustable loading treatments.
- Use the arrows beside each concept to reorder the list. + Click a concept to try it in the session header. Use the arrows beside each concept to reorder the list.
-
+
{(row, idx) => ( - - {"kind" in row ? ( + selectSpinnerLab(row.id)} + > + {"square" in row ? ( + + ) : "kind" in row ? ( "replace" in row ? ( + ) : "trail" in row ? ( + + ) : "frame" in row ? ( + + ) : "overlay" in row ? ( + ) : "shimmer" in row ? ( ) : ( @@ -602,10 +1088,10 @@ export function SessionSidePanel(props: {
{preview(row.name)}
@@ -614,7 +1100,7 @@ export function SessionSidePanel(props: { ) : ( <>
- +
{preview(row.name)}
diff --git a/packages/app/src/pages/session/spinner-lab.tsx b/packages/app/src/pages/session/spinner-lab.tsx new file mode 100644 index 0000000000..36412dbbd9 --- /dev/null +++ b/packages/app/src/pages/session/spinner-lab.tsx @@ -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(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 + +const defaults = Object.fromEntries(spinnerLabIds.map((id) => [id, parse(id)])) as Record +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 ( +
+
{props.title}
+ +
+ ) +} + +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 ( +
+ {mask(props.title, frames()[state.idx] ?? "", state.pos)} +
+ ) +} + +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 ( +
+
{props.title}
+ +
+ ) +} + +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 ( +
+
+ {head()[state.idx] ?? ""} +
+
{props.title}
+
+ {tail()[state.idx] ?? ""} +
+
+ ) +} + +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 ( +
+
{props.title}
+
+ {tail()[state.idx] ?? ""} +
+
+ ) +} + +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 ( +
+ + ) +} + +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: (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(() => { + const cur = cfg() + + if (cur.mode === "current") { + return ( +
+ +
{props.title}
+
+ ) + } + + if (cur.mode === "spin" && cur.kind) { + return ( +
+ +
{props.title}
+
+ ) + } + + if (cur.mode === "shimmer" && cur.kind) { + return ( + + ) + } + + if (cur.mode === "replace" && cur.kind) { + return ( + + ) + } + + if (cur.mode === "overlay" && cur.kind) { + return ( + + ) + } + + if (cur.mode === "trail" && cur.kind) { + return + } + + if (cur.mode === "frame" && cur.kind) { + return + } + + return ( + + ) + }) + + return
{body()}
+}