209 lines
6.6 KiB
TypeScript
209 lines
6.6 KiB
TypeScript
import type { Todo } from "@opencode-ai/sdk/v2"
|
|
import { Checkbox } from "@opencode-ai/ui/checkbox"
|
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
|
import { createStore } from "solid-js/store"
|
|
|
|
function dot(status: Todo["status"]) {
|
|
if (status !== "in_progress") return undefined
|
|
return (
|
|
<svg
|
|
viewBox="0 0 12 12"
|
|
width="12"
|
|
height="12"
|
|
fill="currentColor"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="block"
|
|
>
|
|
<circle
|
|
cx="6"
|
|
cy="6"
|
|
r="3"
|
|
style={{
|
|
animation: "var(--animate-pulse-scale)",
|
|
"transform-origin": "center",
|
|
"transform-box": "fill-box",
|
|
}}
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
|
|
const [store, setStore] = createStore({
|
|
collapsed: false,
|
|
})
|
|
|
|
const toggle = () => setStore("collapsed", (value) => !value)
|
|
|
|
const summary = createMemo(() => {
|
|
const total = props.todos.length
|
|
if (total === 0) return ""
|
|
const completed = props.todos.filter((todo) => todo.status === "completed").length
|
|
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
|
|
})
|
|
|
|
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 ?? "")
|
|
|
|
return (
|
|
<div
|
|
classList={{
|
|
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
|
|
"h-[78px]": store.collapsed,
|
|
}}
|
|
>
|
|
<div
|
|
class="pl-3 pr-2 py-2 flex items-center gap-2"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={toggle}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Enter" && event.key !== " ") return
|
|
event.preventDefault()
|
|
toggle()
|
|
}}
|
|
>
|
|
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
|
|
<Show when={store.collapsed}>
|
|
<div class="ml-1 flex-1 min-w-0">
|
|
<Show when={preview()}>
|
|
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
|
|
<IconButton
|
|
icon="chevron-down"
|
|
size="normal"
|
|
variant="ghost"
|
|
classList={{ "rotate-180": !store.collapsed }}
|
|
onMouseDown={(event) => {
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
}}
|
|
onClick={(event) => {
|
|
event.stopPropagation()
|
|
toggle()
|
|
}}
|
|
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div hidden={store.collapsed}>
|
|
<TodoList todos={props.todos} open={!store.collapsed} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TodoList(props: { todos: Todo[]; open: boolean }) {
|
|
const [stuck, setStuck] = createSignal(false)
|
|
const [scrolling, setScrolling] = createSignal(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 (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)
|
|
}
|
|
|
|
setStuck(scrollRef.scrollTop > 0)
|
|
}
|
|
|
|
createEffect(
|
|
on([() => props.open, inProgress], () => {
|
|
if (!props.open || inProgress() < 0) return
|
|
requestAnimationFrame(ensure)
|
|
}),
|
|
)
|
|
|
|
onCleanup(() => {
|
|
if (!timer) return
|
|
window.clearTimeout(timer)
|
|
})
|
|
|
|
return (
|
|
<div class="relative">
|
|
<div
|
|
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
|
|
ref={scrollRef}
|
|
style={{ "overflow-anchor": "none" }}
|
|
onScroll={(e) => {
|
|
setStuck(e.currentTarget.scrollTop > 0)
|
|
setScrolling(true)
|
|
if (timer) window.clearTimeout(timer)
|
|
timer = window.setTimeout(() => {
|
|
setScrolling(false)
|
|
if (inProgress() < 0) return
|
|
requestAnimationFrame(ensure)
|
|
}, 250)
|
|
}}
|
|
>
|
|
<For each={props.todos}>
|
|
{(todo) => (
|
|
<Checkbox
|
|
readOnly
|
|
checked={todo.status === "completed"}
|
|
indeterminate={todo.status === "in_progress"}
|
|
data-in-progress={todo.status === "in_progress" ? "" : undefined}
|
|
icon={dot(todo.status)}
|
|
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
|
|
>
|
|
<span
|
|
class="text-14-regular min-w-0 break-words"
|
|
classList={{
|
|
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
|
|
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
|
|
}}
|
|
style={{
|
|
"line-height": "var(--line-height-normal)",
|
|
"text-decoration":
|
|
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
|
|
}}
|
|
>
|
|
{todo.content}
|
|
</span>
|
|
</Checkbox>
|
|
)}
|
|
</For>
|
|
</div>
|
|
<div
|
|
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
|
|
style={{
|
|
background: "linear-gradient(to bottom, var(--background-base), transparent)",
|
|
opacity: stuck() ? 1 : 0,
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|