diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 14ba77e26..5e73c6772 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,7 +3,7 @@ import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" -import { createEffect, createMemo, createSignal, For, Match, onMount, ParentProps, Show, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js" import { DiffChanges } from "./diff-changes" import { Typewriter } from "./typewriter" import { Message } from "./message-part" @@ -17,6 +17,13 @@ import { MessageProgress } from "./message-progress" import { Collapsible } from "./collapsible" import { Dynamic } from "solid-js/web" +// Track animation state per message ID - persists across re-renders +// "empty" = first saw with no value (should animate when value arrives) +// "animating" = currently animating (keep returning true) +// "done" = already animated or first saw with value (never animate) +const titleAnimationState = new Map() +const summaryAnimationState = new Map() + export function SessionTurn( props: ParentProps<{ sessionID: string @@ -55,11 +62,37 @@ export function SessionTurn(
{(msg) => { - const titleKey = `app:seen:session:${props.sessionID}:${msg().id}:title` - const contentKey = `app:seen:session:${props.sessionID}:${msg().id}:content` const [detailsExpanded, setDetailsExpanded] = createSignal(false) - const [titled, setTitled] = createSignal(true) - const [faded, setFaded] = createSignal(true) + + // Animation logic: only animate if we witness the value transition from empty to non-empty + // Track in module-level Maps keyed by message ID so it persists across re-renders + + // Initialize animation state for current message (reactive - runs when msg().id changes) + createEffect(() => { + const id = msg().id + if (!titleAnimationState.has(id)) { + titleAnimationState.set(id, msg().summary?.title ? "done" : "empty") + } + if (!summaryAnimationState.has(id)) { + const assistantMsgs = messages()?.filter( + (m) => m.role === "assistant" && m.parentID == id, + ) as AssistantMessage[] + const parts = assistantMsgs?.flatMap((m) => data.store.part[m.id]) + const lastText = parts?.filter((p) => p?.type === "text")?.at(-1) + const summaryValue = msg().summary?.body ?? lastText?.text + summaryAnimationState.set(id, summaryValue ? "done" : "empty") + } + + // When message changes or component unmounts, mark any "animating" states as "done" + onCleanup(() => { + if (titleAnimationState.get(id) === "animating") { + titleAnimationState.set(id, "done") + } + if (summaryAnimationState.get(id) === "animating") { + summaryAnimationState.set(id, "done") + } + }) + }) const assistantMessages = createMemo(() => { return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[] @@ -79,27 +112,38 @@ export function SessionTurn( const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text) const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0) - // allowing time for the animations to finish - onMount(() => { - const titleSeen = sessionStorage.getItem(titleKey) === "true" - const contentSeen = sessionStorage.getItem(contentKey) === "true" - - if (!titleSeen) { - setTitled(false) - const title = msg().summary?.title - if (title) setTimeout(() => setTitled(true), 10_000) - setTimeout(() => sessionStorage.setItem(titleKey, "true"), 1000) + // Should animate: state is "empty" AND value now exists, or state is "animating" + // Transition: empty -> animating -> done (done happens on cleanup) + const animateTitle = createMemo(() => { + const id = msg().id + const state = titleAnimationState.get(id) + const title = msg().summary?.title + if (state === "animating") { + return true } - - if (!contentSeen) { - setFaded(false) - setTimeout(() => sessionStorage.setItem(contentKey, "true"), 1000) + if (state === "empty" && title) { + titleAnimationState.set(id, "animating") + return true } + return false + }) + const animateSummary = createMemo(() => { + const id = msg().id + const state = summaryAnimationState.get(id) + const value = summary() + if (state === "animating") { + return true + } + if (state === "empty" && value) { + summaryAnimationState.set(id, "animating") + return true + } + return false }) createEffect(() => { - const completed = !messageWorking() - setTimeout(() => setCompleted(completed), 1200) + const done = !messageWorking() + setTimeout(() => setCompleted(done), 1200) }) return ( @@ -108,7 +152,7 @@ export function SessionTurn(
} >

{msg().summary?.title}

@@ -133,7 +177,7 @@ export function SessionTurn( )}