| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298 |
- 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) => b.id.localeCompare(a.id)),
- )
- const lastUserMessage = createMemo(() => {
- return userMessages()?.at(0)
- })
- 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)}‎</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>
- )
- }
|