summaryrefslogtreecommitdiffhomepage
path: root/packages/app
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-02 17:24:32 -0500
committerGitHub <[email protected]>2026-03-02 16:24:32 -0600
commit9d7852b5c39b64b96fe1bc14a80a344a0667f0b7 (patch)
treebcbe5c2bac7e5dc203c5e1bf242a83ba5b32aaac /packages/app
parent78069369e2253c9788c09b7a71478d140c9741f2 (diff)
downloadopencode-9d7852b5c39b64b96fe1bc14a80a344a0667f0b7.tar.gz
opencode-9d7852b5c39b64b96fe1bc14a80a344a0667f0b7.zip
Animation Smorgasbord (#15637)
Co-authored-by: Adam <[email protected]>
Diffstat (limited to 'packages/app')
-rw-r--r--packages/app/src/components/prompt-input.tsx80
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx100
-rw-r--r--packages/app/src/pages/session/composer/session-composer-state.ts15
-rw-r--r--packages/app/src/pages/session/composer/session-todo-dock.tsx238
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx57
5 files changed, 381 insertions, 109 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index ba5a33a47..89169af0d 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1,4 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
+import { useSpring } from "@opencode-ai/ui/motion-spring"
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
@@ -255,6 +256,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pendingAutoAccept: false,
})
+ const buttonsSpring = useSpring(
+ () => (store.mode === "normal" ? 1 : 0),
+ { visualDuration: 0.2, bounce: 0 },
+ )
+
const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
@@ -1250,10 +1256,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div
aria-hidden={store.mode !== "normal"}
- class="flex items-center gap-1 transition-all duration-200 ease-out"
- classList={{
- "opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
- "opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
+ class="flex items-center gap-1"
+ style={{
+ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<TooltipKeybind
@@ -1266,6 +1271,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
variant="ghost"
class="size-8 p-0"
+ style={{
+ opacity: buttonsSpring(),
+ transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+ filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+ }}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
@@ -1303,6 +1313,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
+ style={{
+ opacity: buttonsSpring(),
+ transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+ filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+ }}
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
@@ -1355,14 +1370,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
- <div class="flex items-center gap-1.5 min-w-0 flex-1">
- <Show when={store.mode === "shell"}>
- <div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
- <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
- <div class="size-4 shrink-0" />
- </div>
- </Show>
- <Show when={store.mode === "normal"}>
+ <div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
+ <div
+ class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
+ style={{
+ padding: "0 4px 0 8px",
+ opacity: 1 - buttonsSpring(),
+ transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
+ filter: `blur(${buttonsSpring() * 2}px)`,
+ "pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
+ }}
+ >
+ <span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
+ <div class="size-4 shrink-0" />
+ </div>
+ <div class="flex items-center gap-1.5 min-w-0 flex-1">
<TooltipKeybind
placement="top"
gutter={4}
@@ -1376,7 +1398,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
- triggerStyle={{ height: "28px" }}
+ triggerStyle={{
+ height: "28px",
+ opacity: buttonsSpring(),
+ transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+ filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+ }}
variant="ghost"
/>
</TooltipKeybind>
@@ -1394,7 +1422,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
- style={{ height: "28px" }}
+ style={{
+ height: "28px",
+ opacity: buttonsSpring(),
+ transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+ filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+ }}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
@@ -1423,7 +1457,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
triggerProps={{
variant: "ghost",
size: "normal",
- style: { height: "28px" },
+ style: {
+ height: "28px",
+ opacity: buttonsSpring(),
+ transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+ filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+ },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
@@ -1455,11 +1495,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
- triggerStyle={{ height: "28px" }}
+ triggerStyle={{
+ height: "28px",
+ opacity: buttonsSpring(),
+ transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
+ filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
+ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
+ }}
variant="ghost"
/>
</TooltipKeybind>
- </Show>
+ </div>
</div>
<div class="shrink-0">
<RadioGroup
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>&nbsp;{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()}