diff options
Diffstat (limited to 'packages/ui/src/components/session-turn.tsx')
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 220 |
1 files changed, 220 insertions, 0 deletions
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx new file mode 100644 index 000000000..f92089d00 --- /dev/null +++ b/packages/ui/src/components/session-turn.tsx @@ -0,0 +1,220 @@ +import { AssistantMessage } from "@opencode-ai/sdk" +import { useData } from "../context" +import { Binary } from "@opencode-ai/util/binary" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { createEffect, createMemo, createSignal, For, Match, 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 { Diff } from "./diff" +import { Card } from "./card" +import { MessageProgress } from "./message-progress" +import { Collapsible } from "./collapsible" + +export function SessionTurn( + props: ParentProps<{ + sessionID: string + messageID: string + classes?: { + root?: string + content?: string + container?: string + } + }>, +) { + const data = useData() + const match = Binary.search(data.session, props.sessionID, (s) => s.id) + if (!match.found) throw new Error(`Session ${props.sessionID} not found`) + + const messages = createMemo(() => (props.sessionID ? (data.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.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 titleSeen = createMemo(() => true) + const contentSeen = createMemo(() => true) + + const [titled, setTitled] = createSignal(titleSeen()) + const assistantMessages = createMemo(() => { + return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[] + }) + const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error) + const [detailsExpanded, setDetailsExpanded] = createSignal(false) + const parts = createMemo(() => data.part[msg().id]) + const hasToolPart = createMemo(() => + assistantMessages() + ?.flatMap((m) => data.part[m.id]) + .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) + + // allowing time for the animations to finish + createEffect(() => { + if (titleSeen()) return + const title = msg().summary?.title + if (title) setTimeout(() => setTitled(true), 10_000) + }) + createEffect(() => { + const completed = !messageWorking() + setTimeout(() => setCompleted(completed), 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={titled()} + 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()} /> + </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={msg().summary?.body}> + {(summary) => ( + <Markdown + data-slot="session-turn-markdown" + data-diffs={!!msg().summary?.diffs?.length} + data-fade={!msg().summary?.diffs?.length && !contentSeen()} + 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"> + <Diff + before={{ + name: diff.file!, + contents: diff.before!, + }} + after={{ + name: diff.file!, + contents: 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.part[assistantMessage.id]) + return <Message message={assistantMessage} parts={parts()} /> + }} + </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> + ) +} |
