|
@@ -3,7 +3,7 @@ import { useData } from "../context"
|
|
|
import { useDiffComponent } from "../context/diff"
|
|
import { useDiffComponent } from "../context/diff"
|
|
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
|
import { checksum } from "@opencode-ai/util/encode"
|
|
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 { DiffChanges } from "./diff-changes"
|
|
|
import { Typewriter } from "./typewriter"
|
|
import { Typewriter } from "./typewriter"
|
|
|
import { Message } from "./message-part"
|
|
import { Message } from "./message-part"
|
|
@@ -17,6 +17,13 @@ import { MessageProgress } from "./message-progress"
|
|
|
import { Collapsible } from "./collapsible"
|
|
import { Collapsible } from "./collapsible"
|
|
|
import { Dynamic } from "solid-js/web"
|
|
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<string, "empty" | "animating" | "done">()
|
|
|
|
|
+const summaryAnimationState = new Map<string, "empty" | "animating" | "done">()
|
|
|
|
|
+
|
|
|
export function SessionTurn(
|
|
export function SessionTurn(
|
|
|
props: ParentProps<{
|
|
props: ParentProps<{
|
|
|
sessionID: string
|
|
sessionID: string
|
|
@@ -55,11 +62,37 @@ export function SessionTurn(
|
|
|
<div data-slot="session-turn-content" class={props.classes?.content}>
|
|
<div data-slot="session-turn-content" class={props.classes?.content}>
|
|
|
<Show when={message()}>
|
|
<Show when={message()}>
|
|
|
{(msg) => {
|
|
{(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 [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(() => {
|
|
const assistantMessages = createMemo(() => {
|
|
|
return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
|
|
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 summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text)
|
|
|
const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0)
|
|
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(() => {
|
|
createEffect(() => {
|
|
|
- const completed = !messageWorking()
|
|
|
|
|
- setTimeout(() => setCompleted(completed), 1200)
|
|
|
|
|
|
|
+ const done = !messageWorking()
|
|
|
|
|
+ setTimeout(() => setCompleted(done), 1200)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
@@ -108,7 +152,7 @@ export function SessionTurn(
|
|
|
<div data-slot="session-turn-message-header">
|
|
<div data-slot="session-turn-message-header">
|
|
|
<div data-slot="session-turn-message-title">
|
|
<div data-slot="session-turn-message-title">
|
|
|
<Show
|
|
<Show
|
|
|
- when={titled()}
|
|
|
|
|
|
|
+ when={!animateTitle()}
|
|
|
fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
|
|
fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
|
|
|
>
|
|
>
|
|
|
<h1>{msg().summary?.title}</h1>
|
|
<h1>{msg().summary?.title}</h1>
|
|
@@ -133,7 +177,7 @@ export function SessionTurn(
|
|
|
<Markdown
|
|
<Markdown
|
|
|
data-slot="session-turn-markdown"
|
|
data-slot="session-turn-markdown"
|
|
|
data-diffs={!!msg().summary?.diffs?.length}
|
|
data-diffs={!!msg().summary?.diffs?.length}
|
|
|
- data-fade={!msg().summary?.diffs?.length && !faded()}
|
|
|
|
|
|
|
+ data-fade={!msg().summary?.diffs?.length && animateSummary()}
|
|
|
text={summary()}
|
|
text={summary()}
|
|
|
/>
|
|
/>
|
|
|
)}
|
|
)}
|