Files
opencode/packages/ui/src/components/session-turn.tsx
2025-12-11 14:46:32 -06:00

299 lines
14 KiB
TypeScript

import { AssistantMessage } from "@opencode-ai/sdk/v2"
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, onCleanup, ParentProps, Show, Switch } from "solid-js"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
import { Message } from "./message-part"
import { Markdown } from "./markdown"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { Card } from "./card"
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<string, "empty" | "animating" | "done">()
const summaryAnimationState = new Map<string, "empty" | "animating" | "done">()
export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
classes?: {
root?: string
content?: string
container?: string
}
}>,
) {
const data = useData()
const diffComponent = useDiffComponent()
const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => a.id.localeCompare(b.id)),
)
const lastUserMessage = createMemo(() => {
return userMessages()?.at(-1)
})
const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
const status = createMemo(
() =>
data.store.session_status[props.sessionID] ?? {
type: "idle",
},
)
const working = createMemo(() => status()?.type !== "idle")
return (
<div data-component="session-turn" class={props.classes?.root}>
<div data-slot="session-turn-content" class={props.classes?.content}>
<Show when={message()}>
{(msg) => {
const [detailsExpanded, setDetailsExpanded] = createSignal(false)
// 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[]
})
const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
const parts = createMemo(() => data.store.part[msg().id])
const lastTextPart = createMemo(() =>
assistantMessageParts()
.filter((p) => p?.type === "text")
?.at(-1),
)
const hasToolPart = createMemo(() => assistantMessageParts().some((p) => p?.type === "tool"))
const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working())
const initialCompleted = !(msg().id === lastUserMessage()?.id && working())
const [completed, setCompleted] = createSignal(initialCompleted)
const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text)
const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0)
// 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 (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 done = !messageWorking()
setTimeout(() => setCompleted(done), 1200)
})
return (
<div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}>
{/* Title */}
<div data-slot="session-turn-message-header">
<div data-slot="session-turn-message-title">
<Show
when={!animateTitle()}
fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
>
<h1>{msg().summary?.title}</h1>
</Show>
</div>
</div>
<div data-slot="session-turn-message-content">
<Message message={msg()} parts={parts()} sanitize={sanitizer()} />
</div>
{/* Summary */}
<Show when={completed()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<h2 data-slot="session-turn-summary-title">
<Switch>
<Match when={msg().summary?.diffs?.length}>Summary</Match>
<Match when={true}>Response</Match>
</Switch>
</h2>
<Show when={summary()}>
{(summary) => (
<Markdown
data-slot="session-turn-markdown"
data-diffs={!!msg().summary?.diffs?.length}
data-fade={!msg().summary?.diffs?.length && animateSummary()}
text={summary()}
/>
)}
</Show>
</div>
<Accordion data-slot="session-turn-accordion" multiple>
<For each={msg().summary?.diffs ?? []}>
{(diff) => (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-accordion-trigger-content">
<div data-slot="session-turn-file-info">
<FileIcon
node={{ path: diff.file, type: "file" }}
data-slot="session-turn-file-icon"
/>
<div data-slot="session-turn-file-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
</Show>
<span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
</div>
</div>
<div data-slot="session-turn-accordion-actions">
<DiffChanges changes={diff} />
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content data-slot="session-turn-accordion-content">
<Dynamic
component={diffComponent}
before={{
name: diff.file!,
contents: diff.before!,
cacheKey: checksum(diff.before!),
}}
after={{
name: diff.file!,
contents: diff.after!,
cacheKey: checksum(diff.after!),
}}
/>
</Accordion.Content>
</Accordion.Item>
)}
</For>
</Accordion>
</div>
</Show>
<Show when={error() && !detailsExpanded()}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
{/* Response */}
<div data-slot="session-turn-response-section">
<Switch>
<Match when={!completed()}>
<MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
</Match>
<Match when={completed() && hasToolPart()}>
<Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
<Collapsible.Trigger>
<div data-slot="session-turn-collapsible-trigger-content">
<div data-slot="session-turn-details-text">
<Switch>
<Match when={detailsExpanded()}>Hide details</Match>
<Match when={!detailsExpanded()}>Show details</Match>
</Switch>
</div>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div data-slot="session-turn-collapsible-content-inner">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => data.store.part[assistantMessage.id])
const last = createMemo(() =>
parts()
.filter((p) => p?.type === "text")
.at(-1),
)
if (lastTextPartShown() && lastTextPart()?.id === last()?.id) {
return (
<Message
message={assistantMessage}
parts={parts().filter((p) => p?.id !== last()?.id)}
sanitize={sanitizer()}
/>
)
}
return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
}}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
{error()?.data?.message as string}
</Card>
</Show>
</div>
</Collapsible.Content>
</Collapsible>
</Match>
</Switch>
</div>
</div>
)
}}
</Show>
{props.children}
</div>
</div>
)
}