import type { Todo } from "@opencode-ai/sdk/v2" import { AnimatedNumber } from "@opencode-ai/ui/animated-number" import { Checkbox } from "@opencode-ai/ui/checkbox" import { DockTray } from "@opencode-ai/ui/dock-surface" import { IconButton } from "@opencode-ai/ui/icon-button" import { useSpring } from "@opencode-ai/ui/motion-spring" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { Index, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { composerEnabled, composerProbe } from "@/testing/session-composer" import { useLanguage } from "@/context/language" const doneToken = "\u0000done\u0000" const totalToken = "\u0000total\u0000" function dot(status: Todo["status"]) { if (status !== "in_progress") return undefined return ( ) } export function SessionTodoDock(props: { sessionID?: string todos: Todo[] collapseLabel: string expandLabel: string dockProgress: number }) { const language = useLanguage() const [store, setStore] = createStore({ collapsed: false, height: 320, }) const toggle = () => setStore("collapsed", (value) => !value) const total = createMemo(() => props.todos.length) const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() })) const progress = createMemo(() => language .t("session.todo.progress", { done: doneToken, total: totalToken }) .split(/(\u0000done\u0000|\u0000total\u0000)/), ) const active = createMemo( () => props.todos.find((todo) => todo.status === "in_progress") ?? props.todos.find((todo) => todo.status === "pending") ?? props.todos.filter((todo) => todo.status === "completed").at(-1) ?? props.todos[0], ) const preview = createMemo(() => active()?.content ?? "") const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress))) const shut = createMemo(() => 1 - dock()) const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) const hide = createMemo(() => Math.max(value(), shut())) const off = createMemo(() => hide() > 0.98) const turn = createMemo(() => Math.max(0, Math.min(1, value()))) const full = createMemo(() => Math.max(78, store.height)) const e2e = composerEnabled() const probe = composerProbe(props.sessionID) let contentRef: HTMLDivElement | undefined createEffect(() => { const el = contentRef if (!el) return const update = () => { setStore("height", el.getBoundingClientRect().height) } update() const observer = new ResizeObserver(update) observer.observe(el) onCleanup(() => observer.disconnect()) }) createEffect(() => { if (!e2e) return probe.set({ mounted: true, collapsed: store.collapsed, hidden: store.collapsed || off(), count: props.todos.length, states: props.todos.map((todo) => todo.status), }) }) onCleanup(() => { if (!e2e) return probe.drop() }) return (
{ if (event.key !== "Enter" && event.key !== " ") return event.preventDefault() toggle() }} > {(item) => item() === doneToken ? ( ) : item() === totalToken ? ( ) : ( {item()} ) }
{ event.preventDefault() event.stopPropagation() }} onClick={(event) => { event.stopPropagation() toggle() }} aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} />
0.1, }} style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, }} >
) } function TodoList(props: { todos: Todo[]; open: boolean }) { const [store, setStore] = createStore({ stuck: false, scrolling: false, }) let scrollRef!: HTMLDivElement let timer: number | undefined const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress")) const ensure = () => { if (!props.open) return if (store.scrolling) return if (!scrollRef || scrollRef.offsetParent === null) return const el = scrollRef.querySelector("[data-in-progress]") if (!(el instanceof HTMLElement)) return const topFade = 16 const bottomFade = 44 const container = scrollRef.getBoundingClientRect() const rect = el.getBoundingClientRect() const top = rect.top - container.top + scrollRef.scrollTop const bottom = rect.bottom - container.top + scrollRef.scrollTop const viewTop = scrollRef.scrollTop + topFade const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade if (top < viewTop) { scrollRef.scrollTop = Math.max(0, top - topFade) } else if (bottom > viewBottom) { scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade) } setStore("stuck", scrollRef.scrollTop > 0) } createEffect( on([() => props.open, inProgress], () => { if (!props.open || inProgress() < 0) return requestAnimationFrame(ensure) }), ) onCleanup(() => { if (!timer) return window.clearTimeout(timer) }) return (
{ setStore("stuck", e.currentTarget.scrollTop > 0) setStore("scrolling", true) if (timer) window.clearTimeout(timer) timer = window.setTimeout(() => { setStore("scrolling", false) if (inProgress() < 0) return requestAnimationFrame(ensure) }, 250) }} > {(todo) => ( )}
) }