diff options
| author | Kit Langton <[email protected]> | 2026-03-02 17:24:32 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-02 16:24:32 -0600 |
| commit | 9d7852b5c39b64b96fe1bc14a80a344a0667f0b7 (patch) | |
| tree | bcbe5c2bac7e5dc203c5e1bf242a83ba5b32aaac /packages/app/src/pages | |
| parent | 78069369e2253c9788c09b7a71478d140c9741f2 (diff) | |
| download | opencode-9d7852b5c39b64b96fe1bc14a80a344a0667f0b7.tar.gz opencode-9d7852b5c39b64b96fe1bc14a80a344a0667f0b7.zip | |
Animation Smorgasbord (#15637)
Co-authored-by: Adam <[email protected]>
Diffstat (limited to 'packages/app/src/pages')
4 files changed, 318 insertions, 92 deletions
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index cfd78ece8..9f17d3f4b 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,5 +1,6 @@ -import { Show, createEffect, createMemo } from "solid-js" +import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" +import { useSpring } from "@opencode-ai/ui/motion-spring" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -18,6 +19,23 @@ export function SessionComposerRegion(props: { onSubmit: () => void onResponseSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void + visualDuration?: number + bounce?: number + dockOpenVisualDuration?: number + dockOpenBounce?: number + dockCloseVisualDuration?: number + dockCloseBounce?: number + drawerExpandVisualDuration?: number + drawerExpandBounce?: number + drawerCollapseVisualDuration?: number + drawerCollapseBounce?: number + subtitleDuration?: number + subtitleTravel?: number + subtitleEdge?: number + countDuration?: number + countMask?: number + countMaskHeight?: number + countWidthDuration?: number }) { const params = useParams() const prompt = usePrompt() @@ -43,6 +61,40 @@ export function SessionComposerRegion(props: { setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) + const open = createMemo(() => props.state.dock() && !props.state.closing()) + const config = createMemo(() => + open() + ? { + visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3, + bounce: props.dockOpenBounce ?? props.bounce ?? 0, + } + : { + visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3, + bounce: props.dockCloseBounce ?? props.bounce ?? 0, + }, + ) + const progress = useSpring( + () => (open() ? 1 : 0), + config, + ) + const value = createMemo(() => Math.max(0, Math.min(1, progress()))) + const [height, setHeight] = createSignal(320) + const dock = createMemo(() => props.state.dock() || value() > 0.001) + const full = createMemo(() => Math.max(78, height())) + const [contentRef, setContentRef] = createSignal<HTMLDivElement>() + + createEffect(() => { + const el = contentRef() + if (!el) return + const update = () => { + setHeight(el.getBoundingClientRect().height) + } + update() + const observer = new ResizeObserver(update) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) + return ( <div ref={props.setPromptDockRef} @@ -87,30 +139,46 @@ export function SessionComposerRegion(props: { </div> } > - <Show when={props.state.dock()}> + <Show when={dock()}> <div classList={{ - "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true, - "max-h-[320px]": !props.state.closing(), - "max-h-0 pointer-events-none": props.state.closing(), - "opacity-0 translate-y-9": props.state.closing() || props.state.opening(), - "opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(), + "overflow-hidden": true, + "pointer-events-none": value() < 0.98, + }} + style={{ + "max-height": `${full() * value()}px`, }} > - <SessionTodoDock - todos={props.state.todos()} - title={language.t("session.todo.title")} - collapseLabel={language.t("session.todo.collapse")} - expandLabel={language.t("session.todo.expand")} - /> + <div ref={setContentRef}> + <SessionTodoDock + todos={props.state.todos()} + title={language.t("session.todo.title")} + collapseLabel={language.t("session.todo.collapse")} + expandLabel={language.t("session.todo.expand")} + dockProgress={value()} + visualDuration={props.visualDuration} + bounce={props.bounce} + expandVisualDuration={props.drawerExpandVisualDuration} + expandBounce={props.drawerExpandBounce} + collapseVisualDuration={props.drawerCollapseVisualDuration} + collapseBounce={props.drawerCollapseBounce} + subtitleDuration={props.subtitleDuration} + subtitleTravel={props.subtitleTravel} + subtitleEdge={props.subtitleEdge} + countDuration={props.countDuration} + countMask={props.countMask} + countMaskHeight={props.countMaskHeight} + countWidthDuration={props.countWidthDuration} + /> + </div> </div> </Show> <div classList={{ "relative z-10": true, - "transition-[margin] duration-[400ms] ease-out": true, - "-mt-9": props.state.dock() && !props.state.closing(), - "mt-0": !props.state.dock() || props.state.closing(), + }} + style={{ + "margin-top": `${-36 * value()}px`, }} > <PromptInput diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 201846177..03e96463c 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -29,7 +29,11 @@ export function createSessionComposerBlocked() { }) } -export function createSessionComposerState() { +export function createSessionComposerState( + options?: { + closeMs?: number | (() => number) + }, +) { const params = useParams() const sdk = useSDK() const sync = useSync() @@ -96,12 +100,19 @@ export function createSessionComposerState() { 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 - }, 400) + }, closeMs()) } createEffect( diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index 279982760..ab8755aec 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -1,8 +1,12 @@ 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 { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" +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, createSignal, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" function dot(status: Todo["status"]) { @@ -30,19 +34,35 @@ function dot(status: Todo["status"]) { ) } -export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) { +export function SessionTodoDock(props: { + todos: Todo[] + title: string + collapseLabel: string + expandLabel: string + dockProgress?: number + visualDuration?: number + bounce?: number + expandVisualDuration?: number + expandBounce?: number + collapseVisualDuration?: number + collapseBounce?: number + subtitleDuration?: number + subtitleTravel?: number + subtitleEdge?: number + countDuration?: number + countMask?: number + countMaskHeight?: number + countWidthDuration?: number +}) { 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 total = createMemo(() => props.todos.length) + const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) + const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`) const active = createMemo( () => @@ -53,56 +73,134 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL ) const preview = createMemo(() => active()?.content ?? "") + const config = createMemo(() => + store.collapsed + ? { + visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3, + bounce: props.collapseBounce ?? props.bounce ?? 0, + } + : { + visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3, + bounce: props.expandBounce ?? props.bounce ?? 0, + }, + ) + const collapse = useSpring(() => (store.collapsed ? 1 : 0), config) + const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1))) + 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 [height, setHeight] = createSignal(320) + const full = createMemo(() => Math.max(78, height())) + let contentRef: HTMLDivElement | undefined + + createEffect(() => { + const el = contentRef + if (!el) return + const update = () => { + setHeight(el.getBoundingClientRect().height) + } + update() + const observer = new ResizeObserver(update) + observer.observe(el) + onCleanup(() => observer.disconnect()) + }) return ( <DockTray data-component="session-todo-dock" - classList={{ - "h-[78px]": store.collapsed, + style={{ + "overflow-x": "visible", + "overflow-y": "hidden", + "max-height": `${Math.max(78, full() - value() * (full() - 78))}px`, }} > - <div - data-action="session-todo-toggle" - 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 - data-action="session-todo-toggle-button" - icon="chevron-down" - size="normal" - variant="ghost" - classList={{ "rotate-180": store.collapsed }} - onMouseDown={(event) => { - event.preventDefault() - event.stopPropagation() + <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={toggle} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + toggle() + }} + > + <span + class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible" + aria-label={label()} + style={{ + "--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`, + "--tool-motion-mask": `${props.countMask ?? 18}%`, + "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`, + "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`, + opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`, + filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`, }} - onClick={(event) => { - event.stopPropagation() - toggle() + > + <AnimatedNumber value={done()} /> + <span class="mx-1">of</span> + <AnimatedNumber value={total()} /> + <span> {props.title.toLowerCase()} completed</span> + </span> + <div + data-slot="session-todo-preview" + class="ml-1 min-w-0 overflow-hidden" + style={{ + flex: "1 1 auto", + "max-width": "100%", }} - aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} - /> + > + <TextReveal + class="text-14-regular text-text-base cursor-default" + text={store.collapsed ? preview() : undefined} + duration={props.subtitleDuration ?? 600} + travel={props.subtitleTravel ?? 25} + edge={props.subtitleEdge ?? 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={store.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() + toggle() + }} + aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} + /> + </div> </div> - </div> - <div data-slot="session-todo-list" hidden={store.collapsed}> - <TodoList todos={props.todos} open={!store.collapsed} /> + <div + data-slot="session-todo-list" + aria-hidden={store.collapsed || off()} + classList={{ + "pointer-events-none": hide() > 0.1, + }} + style={{ + visibility: off() ? "hidden" : "visible", + opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, + filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`, + }} + > + <TodoList todos={props.todos} open={!store.collapsed} /> + </div> </div> </DockTray> ) @@ -171,33 +269,43 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { }, 250) }} > - <For each={props.todos}> + <Index 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" }} + 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)), filter 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", + opacity: todo().status === "pending" ? "0.94" : "1", + filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)", + }} > - <span + <TextStrikethrough + active={todo().status === "completed" || todo().status === "cancelled"} + text={todo().content} 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, + 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)), filter 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", + filter: todo().status === "pending" ? "blur(0.3px)" : "blur(0px)", }} - > - {todo.content} - </span> + /> </Checkbox> )} - </For> + </Index> </div> <div class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150" diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 7c711989e..0aa07bf74 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -10,8 +10,9 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import type { Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" +import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" @@ -31,6 +32,9 @@ type MessageComment = { } } +const emptyMessages: MessageType[] = [] +const idle = { type: "idle" as const } + const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { if (part.type !== "text" || !(part as TextPart).synthetic) return [] @@ -213,8 +217,34 @@ export function MessageTimeline(props: { const dialog = useDialog() const language = useLanguage() + const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) + const sessionMessages = createMemo(() => { + const id = sessionID() + if (!id) return emptyMessages + return sync.data.message[id] ?? emptyMessages + }) + const pending = createMemo(() => + sessionMessages().findLast( + (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", + ), + ) + const activeMessageID = createMemo(() => { + const parentID = pending()?.parentID + if (!parentID) return + + const messages = sessionMessages() + const result = Binary.search(messages, parentID, (message) => message.id) + const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) + if (!message || message.role !== "user") return + return message.id + }) + const sessionStatus = createMemo(() => { + const id = sessionID() + if (!id) return idle + return sync.data.session_status[id] ?? idle + }) const info = createMemo(() => { const id = sessionID() if (!id) return @@ -651,17 +681,23 @@ export function MessageTimeline(props: { </Button> </div> </Show> - <For each={staging.messages()}> - {(message) => { - const comments = createMemo(() => messageComments(sync.data.part[message.id] ?? [])) + <For each={rendered()}> + {(messageID) => { + const active = createMemo(() => activeMessageID() === messageID) + const queued = createMemo(() => { + const item = pending() + if (!item || active()) return false + return messageID > item.id + }) + const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? [])) const commentCount = createMemo(() => comments().length) return ( <div - id={props.anchor(message.id)} - data-message-id={message.id} + id={props.anchor(messageID)} + data-message-id={messageID} ref={(el) => { - props.onRegisterMessage(el, message.id) - onCleanup(() => props.onUnregisterMessage(message.id)) + props.onRegisterMessage(el, messageID) + onCleanup(() => props.onUnregisterMessage(messageID)) }} classList={{ "min-w-0 w-full max-w-full": true, @@ -701,7 +737,10 @@ export function MessageTimeline(props: { </Show> <SessionTurn sessionID={sessionID() ?? ""} - messageID={message.id} + messageID={messageID} + active={active()} + queued={queued()} + status={active() ? sessionStatus() : undefined} showReasoningSummaries={settings.general.showReasoningSummaries()} shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} editToolDefaultOpen={settings.general.editToolPartsExpanded()} |
