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) => } + +