refactor: remove todo tool (#35989)

This commit is contained in:
Aiden Cline 2026-07-09 00:13:48 -05:00 committed by GitHub
commit 7feefb697f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
250 changed files with 237 additions and 4755 deletions

View file

@ -565,8 +565,6 @@ export default function Page() {
})
let reviewFrame: number | undefined
let todoFrame: number | undefined
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
@ -773,41 +771,6 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
createEffect(
on(
() => {
const id = params.id
return [
sdk().directory,
id,
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync().data.todo[id] !== undefined)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk().directory !== dir || params.id !== id) return
untrack(() => {
void sync().session.todo(id, cached ? { force: true } : undefined)
})
}, 0)
})
},
{ defer: true },
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@ -1882,8 +1845,6 @@ export default function Page() {
onCleanup(() => {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
@ -1898,12 +1859,7 @@ export default function Page() {
sessionKey,
sessionID: () => params.id,
prompt,
ready: () => !store.deferRender && messagesReady(),
centered,
todo: {
collapsed: () => view().todoCollapsed.get(),
onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
},
followup: () =>
params.id && !isChildSession()
? {

View file

@ -1,7 +1,4 @@
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
import type { PromptInputState } from "@/components/prompt-input"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
@ -26,12 +23,7 @@ export function createSessionComposerRegionController(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
prompt: PromptInputState
ready: Accessor<boolean>
centered: Accessor<boolean>
todo: {
collapsed: Accessor<boolean>
onToggle: () => void
}
followup: Accessor<SessionComposerFollowupDock | undefined>
revert: Accessor<SessionComposerRevertDock | undefined>
onResponseSubmit: () => void
@ -40,41 +32,6 @@ export function createSessionComposerRegionController(input: {
setDockRef: (el: HTMLDivElement) => void
}) {
const sync = useSync()
const [store, setStore] = createStore({
ready: input.ready() || input.state.dock(),
height: 320,
body: undefined as HTMLDivElement | undefined,
})
let timer: number | undefined
let frame: number | undefined
const clear = () => {
if (timer !== undefined) window.clearTimeout(timer)
if (frame !== undefined) cancelAnimationFrame(frame)
timer = undefined
frame = undefined
}
createEffect(() => {
input.sessionKey()
const ready = input.ready()
const dock = input.state.dock()
clear()
if (store.ready || (!ready && !dock)) return
if (dock) {
setStore("ready", true)
return
}
frame = requestAnimationFrame(() => {
frame = undefined
timer = window.setTimeout(() => {
setStore("ready", true)
timer = undefined
}, 140)
})
})
createEffect(() => {
if (!input.prompt.ready()) return
@ -92,27 +49,10 @@ export function createSessionComposerRegionController(input: {
})
})
createEffect(() => {
const el = store.body
if (!el) return
const update = () => setStore("height", el.getBoundingClientRect().height)
createResizeObserver(el, update)
update()
})
onCleanup(clear)
const parentID = createMemo(() => {
const id = input.sessionID()
return id ? sync().session.get(id)?.parentID : undefined
})
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
const progress = useSpring(
() => (open() ? 1 : 0),
{ visualDuration: 0.3, bounce: 0 },
() => `${input.sessionKey()}\0${store.ready}`,
)
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
const ready = Promise.resolve()
const [promptReady] = createResource(
() => input.prompt.ready.promise ?? ready,
@ -122,7 +62,6 @@ export function createSessionComposerRegionController(input: {
return {
state: input.state,
centered: input.centered,
todo: input.todo,
followup: input.followup,
revert: input.revert,
onResponseSubmit: input.onResponseSubmit,
@ -134,11 +73,7 @@ export function createSessionComposerRegionController(input: {
showComposer: () => !input.state.blocked() || !!parentID(),
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
promptReady: () => input.prompt.ready() || promptReady(),
dock: () => (store.ready && input.state.dock()) || value() > 0.001,
dockProgress: value,
dockHeight: () => Math.max(78, store.height),
lift: () => (input.revert()?.items.length ? 18 : 36 * value()),
setDockBodyRef: (el: HTMLDivElement) => setStore("body", el),
lift: () => (input.revert()?.items.length ? 18 : 0),
}
}

View file

@ -5,7 +5,6 @@ import { SessionPermissionDock } from "@/pages/session/composer/session-permissi
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { SessionComposerRegionController } from "./session-composer-region-controller"
export function SessionComposerRegion(props: {
@ -60,28 +59,6 @@ export function SessionComposerRegion(props: {
</Show>
<Show when={controller.showComposer()}>
<Show when={controller.dock()}>
<div
classList={{
"overflow-hidden": true,
"pointer-events-none": controller.dockProgress() < 0.98,
}}
style={{
"max-height": `${controller.dockHeight() * controller.dockProgress()}px`,
}}
>
<div ref={controller.setDockBodyRef}>
<SessionTodoDock
todos={controller.state.todos()}
collapsed={controller.todo.collapsed()}
onToggle={controller.todo.onToggle}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
dockProgress={controller.dockProgress()}
/>
</div>
</div>
</Show>
<Show
when={controller.promptReady()}
fallback={
@ -98,10 +75,7 @@ export function SessionComposerRegion(props: {
</div>
)}
</Show>
<div
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
style={{ "margin-top": `${-36 * controller.dockProgress()}px` }}
>
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{controller.handoffPrompt() || language.t("prompt.loading")}
</div>
</>
@ -109,11 +83,7 @@ export function SessionComposerRegion(props: {
>
<Show when={rolled()} keyed>
{(revert) => (
<div
style={{
"margin-top": `${-36 * controller.dockProgress()}px`,
}}
>
<div>
<SessionRevertDock
items={revert.items}
restoring={revert.restoring}

View file

@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
const session = (input: { id: string; parentID?: string }) =>
@ -104,35 +103,3 @@ describe("sessionQuestionRequest", () => {
expect(sessionQuestionRequest(sessions, questions, "root")?.id).toBe("q-grand")
})
})
describe("todoState", () => {
test("hides when there are no todos", () => {
expect(todoState({ count: 0, done: false, live: true })).toBe("hide")
})
test("opens while the session is still working", () => {
expect(todoState({ count: 2, done: false, live: true })).toBe("open")
})
test("closes completed todos after a running turn", () => {
expect(todoState({ count: 2, done: true, live: true })).toBe("close")
})
test("clears stale todos when the turn ends", () => {
expect(todoState({ count: 2, done: false, live: false })).toBe("clear")
})
test("clears completed todos when the session is no longer live", () => {
expect(todoState({ count: 2, done: true, live: false })).toBe("clear")
})
})
describe("todoDockAtBoundary", () => {
test("shows active todos when entering a session", () => {
expect(todoDockAtBoundary("open")).toBe(true)
})
test("hides completed todos when entering a session", () => {
expect(todoDockAtBoundary("close")).toBe(false)
})
})

View file

@ -1,35 +1,20 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
export const todoState = (input: {
count: number
done: boolean
live: boolean
}): "hide" | "clear" | "open" | "close" => {
if (input.count === 0) return "hide"
if (!input.live) return "clear"
if (!input.done) return "open"
return "close"
}
export const todoDockAtBoundary = (state: ReturnType<typeof todoState>) => state === "open"
const idle = { type: "idle" as const }
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
export function createSessionComposerController() {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const language = useLanguage()
const permission = usePermission()
@ -49,24 +34,8 @@ export function createSessionComposerController(options?: { closeMs?: number | (
return !!permissionRequest() || !!questionRequest()
})
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return serverSync().session.data.todo[id] ?? []
})
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
const [store, setStore] = createStore({
sessionID: params.id,
responding: undefined as string | undefined,
dock: todos().length > 0 && !done() && live(),
closing: false,
opening: false,
})
const permissionResponding = createMemo(() => {
@ -92,112 +61,12 @@ export function createSessionComposerController(options?: { closeMs?: number | (
})
}
let timer: number | undefined
let raf: number | undefined
const closeMs = () => {
const value = options?.closeMs
if (typeof value === "function") return Math.max(0, value())
if (typeof value === "number") return Math.max(0, value)
return 400
}
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore({ dock: false, closing: false })
timer = undefined
}, closeMs())
}
// Keep stale turn todos from reopening if the model never clears them.
const clear = () => {
const id = params.id
if (!id) return
sync().set("todo", id, [])
}
createEffect(
on(
() => [params.id, todos().length, done(), live()] as const,
([id, count, complete, active], previous) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
const next = todoState({
count,
done: complete,
live: active,
})
if (!previous || previous[0] !== id) {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
if (next === "clear") clear()
return
}
if (next === "hide") {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ dock: false, closing: false, opening: false })
return
}
if (next === "clear") {
if (timer) window.clearTimeout(timer)
timer = undefined
clear()
return
}
if (next === "open") {
if (timer) window.clearTimeout(timer)
timer = undefined
const hidden = !store.dock || store.closing
setStore({ dock: true, closing: false })
if (hidden) {
setStore("opening", true)
raf = requestAnimationFrame(() => {
setStore("opening", false)
raf = undefined
})
return
}
setStore("opening", false)
return
}
setStore({ dock: true, opening: false, closing: true })
if (!timer) scheduleClose()
},
),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
onCleanup(() => {
if (!raf) return
cancelAnimationFrame(raf)
})
return {
blocked,
questionRequest,
permissionRequest,
permissionResponding,
decide,
todos,
dock: () =>
store.sessionID === params.id
? store.dock
: todoDockAtBoundary(todoState({ count: todos().length, done: done(), live: live() })),
closing: () => store.sessionID === params.id && store.closing,
opening: () => store.sessionID === params.id && store.opening,
}
}

View file

@ -1,256 +0,0 @@
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 { createResizeObserver } from "@solid-primitives/resize-observer"
import { Index, createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
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 (
<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[]
collapsed: boolean
onToggle: () => void
collapseLabel: string
expandLabel: string
dockProgress: number
}) {
const language = useLanguage()
const [store, setStore] = createStore({
height: 78,
})
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(() => (props.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))
let contentRef: HTMLDivElement | undefined
createEffect(() => {
const el = contentRef
if (!el) return
const update = () => {
setStore("height", (height) => Math.max(height, el.scrollHeight))
}
update()
createResizeObserver(el, update)
})
return (
<DockTray
data-component="session-todo-dock"
style={{
"overflow-x": "visible",
"overflow-y": "hidden",
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
}}
>
<div ref={contentRef}>
<div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
role="button"
tabIndex={0}
onClick={props.onToggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
props.onToggle()
}}
>
<span
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 overflow-visible"
aria-label={label()}
style={{
"--tool-motion-odometer-ms": "600ms",
"--tool-motion-mask": "18%",
"--tool-motion-mask-height": "0px",
"--tool-motion-spring-ms": "560ms",
"white-space": "pre",
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
}}
>
<Index each={progress()}>
{(item) =>
item() === doneToken ? (
<AnimatedNumber value={done()} />
) : item() === totalToken ? (
<AnimatedNumber value={total()} />
) : (
<span>{item()}</span>
)
}
</Index>
</span>
<div
data-slot="session-todo-preview"
class="ml-1 min-w-0 overflow-hidden"
style={{
flex: "1 1 auto",
"max-width": "100%",
}}
>
<TextReveal
class="text-14-regular text-text-base cursor-default"
text={props.collapsed ? preview() : undefined}
duration={600}
travel={25}
edge={17}
spring="cubic-bezier(0.34, 1, 0.64, 1)"
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
growOnly
truncate
/>
</div>
<div class="ml-auto">
<IconButton
data-action="session-todo-toggle-button"
data-collapsed={props.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${turn() * 180}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
props.onToggle()
}}
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<div
data-slot="session-todo-list"
aria-hidden={props.collapsed || off()}
classList={{
"pointer-events-none": hide() > 0.1,
}}
style={{
visibility: off() ? "hidden" : "visible",
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
}}
>
<TodoList todos={props.todos} />
</div>
</div>
</DockTray>
)
}
function TodoList(props: { todos: Todo[] }) {
const [store, setStore] = createStore({
stuck: false,
})
return (
<div class="relative">
<div
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
style={{ "overflow-anchor": "none" }}
onScroll={(e) => {
setStore("stuck", e.currentTarget.scrollTop > 0)
}}
>
<Index each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo().status === "completed"}
indeterminate={todo().status === "in_progress"}
data-in-progress={todo().status === "in_progress" ? "" : undefined}
data-state={todo().status}
icon={dot(todo().status)}
style={{
"--checkbox-align": "flex-start",
"--checkbox-offset": "1px",
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
opacity: todo().status === "pending" ? "0.94" : "1",
}}
>
<TextStrikethrough
active={todo().status === "completed" || todo().status === "cancelled"}
text={todo().content}
class="text-14-regular min-w-0 break-words"
style={{
"line-height": "var(--line-height-normal)",
transition:
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
color:
todo().status === "completed" || todo().status === "cancelled"
? "var(--text-weak)"
: "var(--text-strong)",
opacity: todo().status === "pending" ? "0.92" : "1",
}}
/>
</Checkbox>
)}
</Index>
</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: store.stuck ? 1 : 0,
}}
/>
</div>
)
}

View file

@ -1,623 +0,0 @@
// @ts-nocheck
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2"
import { useServerSync } from "@/context/global-sync"
import { PromptInput } from "@/components/prompt-input"
import { usePrompt } from "@/context/prompt"
import {
SessionComposerRegion,
createSessionComposerController,
createSessionComposerRegionController,
} from "@/pages/session/composer"
export default {
title: "UI/Todo Panel Motion",
id: "components-todo-panel-motion",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
This playground renders the real session composer region from app code.
### Source path
- \`packages/app/src/pages/session/composer/session-composer-region.tsx\`
### Includes
- \`SessionTodoDock\` (real)
- \`PromptInput\` (real)
No visual reimplementation layer is used for the dock/input stack.`,
},
},
},
}
const pool = [
"Refactor ToolStatusTitle DOM measurement to offscreen global measurer (unconstrained by timeline layout)",
"Remove inline measure nodes/CSS hooks and keep width morph behavior intact",
"Run typechecks/tests and report what changed",
"Verify reduced-motion behavior in timeline",
"Review diff for animation edge cases",
"Document rollout notes in PR description",
"Check keyboard and screen reader semantics",
"Add storybook controls for iteration speed",
]
const btn = (accent?: boolean) =>
({
padding: "6px 14px",
"border-radius": "6px",
border: "1px solid var(--color-divider, #333)",
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
color: "var(--color-text, #eee)",
cursor: "pointer",
"font-size": "13px",
}) as const
const controls = {
agents: { available: [], options: ["build"], current: "build", loading: false, visible: true, select: () => {} },
model: {
selection: {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
paid: true,
loading: false,
},
session: {
id: "story-session",
tabs: { active: () => undefined, all: () => [], open: () => {}, setActive: () => {} },
reviewPanel: { opened: () => false, open: () => {} },
},
newLayoutDesigns: true,
}
const css = `
[data-component="todo-stage"] {
display: grid;
gap: 20px;
padding: 20px;
}
[data-component="todo-preview"] {
height: 560px;
min-height: 0;
}
[data-component="todo-session-root"] {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--background-base);
border: 1px solid var(--border-weak-base);
border-radius: 12px;
}
[data-component="todo-session-frame"] {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
[data-component="todo-session-panel"] {
position: relative;
flex: 1 1 auto;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
background: var(--background-stronger);
}
[data-slot="todo-preview-content"] {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
[data-slot="todo-preview-scroll"] {
height: 100%;
overflow: auto;
min-height: 0;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
[data-slot="todo-preview-spacer"] {
flex: 1 1 auto;
min-height: 0;
}
[data-slot="todo-preview-msg"] {
border-radius: 8px;
border: 1px solid var(--border-weak-base);
background: var(--surface-base);
color: var(--text-weak);
padding: 8px 10px;
font-size: 13px;
line-height: 1.35;
}
[data-slot="todo-preview-msg"][data-strong="true"] {
color: var(--text-strong);
}
`
export const Playground = {
render: () => {
const global = useServerSync()
const prompt = usePrompt()
const [cfg, setCfg] = createStore({
open: true,
collapsed: false,
step: 1,
dockOpenDuration: 0.3,
dockOpenBounce: 0,
dockCloseDuration: 0.3,
dockCloseBounce: 0,
drawerExpandDuration: 0.3,
drawerExpandBounce: 0,
drawerCollapseDuration: 0.3,
drawerCollapseBounce: 0,
subtitleDuration: 600,
subtitleAuto: true,
subtitleTravel: 25,
subtitleEdge: 17,
countDuration: 600,
countMask: 18,
countMaskHeight: 0,
countWidthDuration: 560,
})
const open = () => cfg.open
const step = () => cfg.step
const dockOpenDuration = () => cfg.dockOpenDuration
const dockOpenBounce = () => cfg.dockOpenBounce
const dockCloseDuration = () => cfg.dockCloseDuration
const dockCloseBounce = () => cfg.dockCloseBounce
const drawerExpandDuration = () => cfg.drawerExpandDuration
const drawerExpandBounce = () => cfg.drawerExpandBounce
const drawerCollapseDuration = () => cfg.drawerCollapseDuration
const drawerCollapseBounce = () => cfg.drawerCollapseBounce
const subtitleDuration = () => cfg.subtitleDuration
const subtitleAuto = () => cfg.subtitleAuto
const subtitleTravel = () => cfg.subtitleTravel
const subtitleEdge = () => cfg.subtitleEdge
const countDuration = () => cfg.countDuration
const countMask = () => cfg.countMask
const countMaskHeight = () => cfg.countMaskHeight
const countWidthDuration = () => cfg.countWidthDuration
const state = createSessionComposerController({ closeMs: () => Math.round(dockCloseDuration() * 1000) })
let frame
let scrollRef
const todos = createMemo<Todo[]>(() => {
const done = Math.max(0, Math.min(3, step()))
return pool.slice(0, 3).map((content, i) => ({
id: `todo-${i + 1}`,
content,
status: i < done ? "completed" : i === done && done < 3 ? "in_progress" : "pending",
}))
})
createEffect(() => {
global.todo.set("story-session", todos())
})
const clear = () => {
if (frame) cancelAnimationFrame(frame)
frame = undefined
}
const pin = () => {
if (!scrollRef) return
scrollRef.scrollTop = scrollRef.scrollHeight
}
const collapsed = () => cfg.collapsed
const setCollapsed = (value: boolean) => setCfg("collapsed", value)
const openDock = () => {
clear()
setCfg("open", true)
frame = requestAnimationFrame(() => {
pin()
frame = undefined
})
}
const closeDock = () => {
clear()
setCfg("open", false)
}
const dockOpen = () => open()
const toggleDock = () => {
if (dockOpen()) {
closeDock()
return
}
openDock()
}
const toggleDrawer = () => {
if (!dockOpen()) {
openDock()
frame = requestAnimationFrame(() => {
pin()
setCollapsed(true)
frame = undefined
})
return
}
setCollapsed(!collapsed())
}
const cycle = () => {
setCfg("step", (value) => (value + 1) % 4)
}
onCleanup(clear)
return (
<div data-component="todo-stage">
<style>{css}</style>
<div data-component="todo-preview">
<div data-component="todo-session-root">
<div data-component="todo-session-frame">
<div data-component="todo-session-panel">
<div data-slot="todo-preview-content">
<div data-slot="todo-preview-scroll" class="scroll-view__viewport" ref={scrollRef}>
<div data-slot="todo-preview-spacer" />
<div data-slot="todo-preview-msg" data-strong="true">
Thinking Checking type safety
</div>
<div data-slot="todo-preview-msg">Shell Prints five topic blocks between timed commands</div>
</div>
</div>
<div>
<SessionComposerRegion
controller={createSessionComposerRegionController({
state,
sessionKey: () => "story-session",
sessionID: () => "story-session",
prompt,
ready: () => true,
centered: () => false,
todo: { collapsed, onToggle: () => setCollapsed(!collapsed()) },
followup: () => undefined,
revert: () => undefined,
onResponseSubmit: pin,
openParent: () => {},
setPromptRef: () => {},
setDockRef: () => {},
})}
promptInput={
<PromptInput
controls={controls}
submission={{ abort: () => {}, handleSubmit: (event) => event.preventDefault() }}
ref={() => {}}
newSessionWorktree=""
onNewSessionWorktreeReset={() => {}}
/>
}
/>
</div>
</div>
</div>
</div>
</div>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
<button onClick={toggleDock} style={btn(dockOpen())}>
{dockOpen() ? "Animate close" : "Animate open"}
</button>
<button onClick={toggleDrawer} style={btn(dockOpen() && collapsed())}>
{dockOpen() && collapsed() ? "Expand todo dock" : "Collapse todo dock"}
</button>
<button onClick={cycle} style={btn(step() > 0)}>
Cycle progress ({step()}/3 done)
</button>
{[0, 1, 2, 3].map((value) => (
<button onClick={() => setCfg("step", value)} style={btn(step() === value)}>
{value} done
</button>
))}
</div>
<div style={{ display: "grid", gap: "10px", "max-width": "560px" }}>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)" }}>Dock open</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={dockOpenDuration()}
onInput={(event) => setCfg("dockOpenDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(dockOpenDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={dockOpenBounce()}
onInput={(event) => setCfg("dockOpenBounce", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{dockOpenBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Dock close
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={dockCloseDuration()}
onInput={(event) => setCfg("dockCloseDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(dockCloseDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={dockCloseBounce()}
onInput={(event) => setCfg("dockCloseBounce", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{dockCloseBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Drawer expand
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={drawerExpandDuration()}
onInput={(event) => setCfg("drawerExpandDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(drawerExpandDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={drawerExpandBounce()}
onInput={(event) => setCfg("drawerExpandBounce", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{drawerExpandBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Drawer collapse
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="0.1"
max="1"
step="0.01"
value={drawerCollapseDuration()}
onInput={(event) => setCfg("drawerCollapseDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(drawerCollapseDuration() * 1000)}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
bounce
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={drawerCollapseBounce()}
onInput={(event) => setCfg("drawerCollapseBounce", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{drawerCollapseBounce().toFixed(2)}
</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Subtitle odometer
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="120"
max="1400"
step="10"
value={subtitleDuration()}
onInput={(event) => setCfg("subtitleDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(subtitleDuration())}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
auto fit
</span>
<input
type="checkbox"
checked={subtitleAuto()}
onInput={(event) => setCfg("subtitleAuto", event.currentTarget.checked)}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{subtitleAuto() ? "on" : "off"}
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
travel
</span>
<input
type="range"
min="0"
max="40"
step="1"
value={subtitleTravel()}
onInput={(event) => setCfg("subtitleTravel", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleTravel()}px</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
edge
</span>
<input
type="range"
min="1"
max="40"
step="1"
value={subtitleEdge()}
onInput={(event) => setCfg("subtitleEdge", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{subtitleEdge()}%</span>
</label>
<div style={{ "font-size": "12px", color: "var(--color-text-secondary, #a3a3a3)", "margin-top": "4px" }}>
Count odometer
</div>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
duration
</span>
<input
type="range"
min="120"
max="1400"
step="10"
value={countDuration()}
onInput={(event) => setCfg("countDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(countDuration())}ms
</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
mask
</span>
<input
type="range"
min="4"
max="40"
step="1"
value={countMask()}
onInput={(event) => setCfg("countMask", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMask()}%</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
mask height
</span>
<input
type="range"
min="0"
max="14"
step="1"
value={countMaskHeight()}
onInput={(event) => setCfg("countMaskHeight", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>{countMaskHeight()}px</span>
</label>
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
<span style={{ width: "110px", "font-size": "13px", color: "var(--color-text-secondary, #a3a3a3)" }}>
width spring
</span>
<input
type="range"
min="0"
max="1200"
step="10"
value={countWidthDuration()}
onInput={(event) => setCfg("countWidthDuration", event.currentTarget.valueAsNumber)}
style={{ flex: 1 }}
/>
<span style={{ width: "64px", "text-align": "right", "font-size": "13px" }}>
{Math.round(countWidthDuration())}ms
</span>
</label>
</div>
</div>
)
},
}