diff options
Diffstat (limited to 'packages/ui/src/components/session-turn.tsx')
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 554 |
1 files changed, 206 insertions, 348 deletions
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f1aee802e..3323a9fc6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -3,27 +3,23 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "../context/file" -import { same } from "@opencode-ai/util/array" import { Binary } from "@opencode-ai/util/binary" import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" -import { GrowBox } from "./grow-box" -import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part" +import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part" import { Card } from "./card" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Collapsible } from "./collapsible" import { DiffChanges } from "./diff-changes" import { Icon } from "./icon" -import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { TextReveal } from "./text-reveal" -import { list } from "./text-utils" import { SessionRetry } from "./session-retry" -import { Tooltip } from "./tooltip" +import { TextReveal } from "./text-reveal" import { createAutoScroll } from "../hooks" import { useI18n } from "../context/i18n" + function record(value: unknown): value is Record<string, unknown> { return !!value && typeof value === "object" && !Array.isArray(value) } @@ -77,12 +73,18 @@ function unwrap(message: string) { return message } +function same<T>(a: readonly T[], b: readonly T[]) { + if (a === b) return true + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + +function list<T>(value: T[] | undefined | null, fallback: T[]) { + if (Array.isArray(value)) return value + return fallback +} + const hidden = new Set(["todowrite", "todoread"]) -const emptyMessages: MessageType[] = [] -const emptyAssistant: AssistantMessage[] = [] -const emptyDiffs: FileDiff[] = [] -const idle: SessionStatus = { type: "idle" as const } -const handoffHoldMs = 120 function partState(part: PartType, showReasoningSummaries: boolean) { if (part.type === "tool") { @@ -139,7 +141,6 @@ export function SessionTurn( props: ParentProps<{ sessionID: string messageID: string - animate?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean @@ -158,7 +159,11 @@ export function SessionTurn( const i18n = useI18n() const fileComponent = useFileComponent() + const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] + const emptyAssistant: AssistantMessage[] = [] + const emptyDiffs: FileDiff[] = [] + const idle = { type: "idle" as const } const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) @@ -186,8 +191,42 @@ export function SessionTurn( return msg }) - const active = createMemo(() => props.active ?? false) - const queued = createMemo(() => props.queued ?? false) + const pending = createMemo(() => { + if (typeof props.active === "boolean" && typeof props.queued === "boolean") return + const messages = allMessages() ?? emptyMessages + return messages.findLast( + (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", + ) + }) + + const pendingUser = createMemo(() => { + const item = pending() + if (!item?.parentID) return + const messages = allMessages() ?? emptyMessages + const result = Binary.search(messages, item.parentID, (m) => m.id) + const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID) + if (!msg || msg.role !== "user") return + return msg + }) + + const active = createMemo(() => { + if (typeof props.active === "boolean") return props.active + const msg = message() + const parent = pendingUser() + if (!msg || !parent) return false + return parent.id === msg.id + }) + + const queued = createMemo(() => { + if (typeof props.queued === "boolean") return props.queued + const id = message()?.id + if (!id) return false + if (!pendingUser()) return false + const item = pending() + if (!item) return false + return id > item.id + }) + const parts = createMemo(() => { const msg = message() if (!msg) return emptyParts @@ -250,7 +289,7 @@ export function SessionTurn( const error = createMemo( () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, ) - const assistantCopyPart = createMemo(() => { + const showAssistantCopyPartID = createMemo(() => { const messages = assistantMessages() for (let i = messages.length - 1; i >= 0; i--) { @@ -260,18 +299,13 @@ export function SessionTurn( const parts = list(data.store.part?.[message.id], emptyParts) for (let j = parts.length - 1; j >= 0; j--) { const part = parts[j] - if (!part || part.type !== "text") continue - const text = part.text?.trim() - if (!text) continue - return { - id: part.id, - text, - message, - } + if (!part || part.type !== "text" || !part.text?.trim()) continue + return part.id } } + + return undefined }) - const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) @@ -279,14 +313,18 @@ export function SessionTurn( return unwrap(String(msg)) }) - const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) - const working = createMemo(() => { - if (status().type === "idle") return false - if (!message()) return false - return active() + const status = createMemo(() => { + if (props.status !== undefined) return props.status + if (typeof props.active === "boolean" && !props.active) return idle + return data.store.session_status[props.sessionID] ?? idle }) + const working = createMemo(() => status().type !== "idle" && active()) const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) - const showDiffSummary = createMemo(() => edited() > 0 && !working()) + + const assistantCopyPartID = createMemo(() => { + if (working()) return null + return showAssistantCopyPartID() ?? null + }) const turnDurationMs = createMemo(() => { const start = message()?.time.created if (typeof start !== "number") return undefined @@ -326,109 +364,13 @@ export function SessionTurn( .filter((text): text is string => !!text) .at(-1), ) - const thinking = createMemo(() => { + const showThinking = createMemo(() => { if (!working() || !!error()) return false if (queued()) return false if (status().type === "retry") return false if (showReasoningSummaries()) return assistantVisible() === 0 return true }) - const hasAssistant = createMemo(() => assistantMessages().length > 0) - const animateEnabled = createMemo(() => props.animate !== false) - const [live, setLive] = createSignal(false) - const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled())) - const metaOpen = createMemo(() => !working() && !!assistantCopyPart()) - const duration = createMemo(() => { - const ms = turnDurationMs() - if (typeof ms !== "number" || ms < 0) return "" - - const total = Math.round(ms / 1000) - if (total < 60) return `${total}s` - - const minutes = Math.floor(total / 60) - const seconds = total % 60 - return `${minutes}m ${seconds}s` - }) - const meta = createMemo(() => { - const item = assistantCopyPart() - if (!item) return "" - - const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : "" - const model = item.message.modelID - ? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[ - item.message.modelID - ]?.name ?? item.message.modelID) - : "" - return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0") - }) - const [copied, setCopied] = createSignal(false) - const [handoffHold, setHandoffHold] = createSignal(false) - const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold()) - const handoffOpen = createMemo(() => thinkingVisible() || metaOpen()) - const lane = createMemo(() => hasAssistant() || handoffOpen()) - - let liveFrame: number | undefined - let copiedTimer: ReturnType<typeof setTimeout> | undefined - let handoffTimer: ReturnType<typeof setTimeout> | undefined - - const copyAssistant = async () => { - const text = assistantCopyPart()?.text - if (!text) return - - await navigator.clipboard.writeText(text) - setCopied(true) - if (copiedTimer !== undefined) clearTimeout(copiedTimer) - copiedTimer = setTimeout(() => { - copiedTimer = undefined - setCopied(false) - }, 2000) - } - - createEffect( - on( - () => [animateEnabled(), working()] as const, - ([enabled, isWorking]) => { - if (liveFrame !== undefined) { - cancelAnimationFrame(liveFrame) - liveFrame = undefined - } - if (!enabled || !isWorking || live()) return - liveFrame = requestAnimationFrame(() => { - liveFrame = undefined - setLive(true) - }) - }, - ), - ) - - createEffect( - on( - () => [thinkingOpen(), metaOpen()] as const, - ([thinkingNow, metaNow]) => { - if (handoffTimer !== undefined) { - clearTimeout(handoffTimer) - handoffTimer = undefined - } - - if (thinkingNow) { - setHandoffHold(true) - return - } - - if (metaNow) { - setHandoffHold(false) - return - } - - if (!handoffHold()) return - handoffTimer = setTimeout(() => { - handoffTimer = undefined - setHandoffHold(false) - }, handoffHoldMs) - }, - { defer: true }, - ), - ) const autoScroll = createAutoScroll({ working, @@ -436,119 +378,6 @@ export function SessionTurn( overflowAnchor: "dynamic", }) - onCleanup(() => { - if (liveFrame !== undefined) cancelAnimationFrame(liveFrame) - if (copiedTimer !== undefined) clearTimeout(copiedTimer) - if (handoffTimer !== undefined) clearTimeout(handoffTimer) - }) - - const turnDiffSummary = () => ( - <div data-slot="session-turn-diffs"> - <Collapsible open={open()} onOpenChange={setOpen} variant="ghost"> - <Collapsible.Trigger> - <div data-component="session-turn-diffs-trigger"> - <div data-slot="session-turn-diffs-title"> - <span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span> - <span data-slot="session-turn-diffs-count"> - {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} - </span> - <div data-slot="session-turn-diffs-meta"> - <DiffChanges changes={diffs()} variant="bars" /> - <Collapsible.Arrow /> - </div> - </div> - </div> - </Collapsible.Trigger> - <Collapsible.Content> - <Show when={open()}> - <div data-component="session-turn-diffs-content"> - <Accordion - multiple - style={{ "--sticky-accordion-offset": "37px" }} - value={expanded()} - onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} - > - <For each={diffs()}> - {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) - const [visible, setVisible] = createSignal(false) - - createEffect( - on( - active, - (value) => { - if (!value) { - setVisible(false) - return - } - - requestAnimationFrame(() => { - if (!active()) return - setVisible(true) - }) - }, - { defer: true }, - ), - ) - - return ( - <Accordion.Item value={diff.file}> - <StickyAccordionHeader> - <Accordion.Trigger> - <div data-slot="session-turn-diff-trigger"> - <span data-slot="session-turn-diff-path"> - <Show when={diff.file.includes("/")}> - <span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span> - </Show> - <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span> - </span> - <div data-slot="session-turn-diff-meta"> - <span data-slot="session-turn-diff-changes"> - <DiffChanges changes={diff} /> - </span> - <span data-slot="session-turn-diff-chevron"> - <Icon name="chevron-down" size="small" /> - </span> - </div> - </div> - </Accordion.Trigger> - </StickyAccordionHeader> - <Accordion.Content> - <Show when={visible()}> - <div data-slot="session-turn-diff-view" data-scrollable> - <Dynamic - component={fileComponent} - mode="diff" - before={{ name: diff.file, contents: diff.before }} - after={{ name: diff.file, contents: diff.after }} - /> - </div> - </Show> - </Accordion.Content> - </Accordion.Item> - ) - }} - </For> - </Accordion> - </div> - </Show> - </Collapsible.Content> - </Collapsible> - </div> - ) - - const divider = (label: string) => ( - <div data-component="compaction-part"> - <div data-slot="compaction-part-divider"> - <span data-slot="compaction-part-line" /> - <span data-slot="compaction-part-label" class="text-12-regular text-text-weak"> - {label} - </span> - <span data-slot="compaction-part-line" /> - </div> - </div> - ) - return ( <div data-component="session-turn" class={props.classes?.root}> <div @@ -559,120 +388,149 @@ export function SessionTurn( > <div onClick={autoScroll.handleInteraction}> <Show when={message()}> - {(msg) => ( - <div - ref={autoScroll.contentRef} - data-message={msg().id} - data-slot="session-turn-message-container" - class={props.classes?.container} - > - <div data-slot="session-turn-message-content" aria-live="off"> - <UserMessageDisplay - message={msg()} - parts={parts()} - interrupted={interrupted()} - animate={props.animate} - queued={queued()} + <div + ref={autoScroll.contentRef} + data-message={message()!.id} + data-slot="session-turn-message-container" + class={props.classes?.container} + > + <div data-slot="session-turn-message-content" aria-live="off"> + <Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} /> + </div> + <Show when={compaction()}> + <div data-slot="session-turn-compaction"> + <Part part={compaction()!} message={message()!} hideDetails /> + </div> + </Show> + <Show when={assistantMessages().length > 0}> + <div data-slot="session-turn-assistant-content" aria-hidden={working()}> + <AssistantParts + messages={assistantMessages()} + showAssistantCopyPartID={assistantCopyPartID()} + turnDurationMs={turnDurationMs()} + working={working()} + showReasoningSummaries={showReasoningSummaries()} + shellToolDefaultOpen={props.shellToolDefaultOpen} + editToolDefaultOpen={props.editToolDefaultOpen} /> </div> - <Show when={compaction()}> - {(part) => ( - <GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0"> - <div data-slot="session-turn-compaction"> - <Part part={part()} message={msg()} hideDetails /> - </div> - </GrowBox> - )} - </Show> - <div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}> - <Show when={hasAssistant()}> - <div - data-slot="session-turn-assistant-content" - aria-hidden={working()} - style={{ contain: "layout paint" }} - > - <AssistantParts - messages={assistantMessages()} - showAssistantCopyPartID={assistantCopyPartID()} - showTurnDiffSummary={showDiffSummary()} - turnDiffSummary={turnDiffSummary} - working={working()} - animate={live()} - showReasoningSummaries={showReasoningSummaries()} - shellToolDefaultOpen={props.shellToolDefaultOpen} - editToolDefaultOpen={props.editToolDefaultOpen} - /> - </div> + </Show> + <Show when={showThinking()}> + <div data-slot="session-turn-thinking"> + <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} /> + <Show when={!showReasoningSummaries()}> + <TextReveal + text={reasoningHeading()} + class="session-turn-thinking-heading" + travel={25} + duration={700} + /> </Show> - <GrowBox - animate={live()} - animateToggle={live()} - open={handoffOpen()} - fade - slot="session-turn-handoff-wrap" - > - <div data-slot="session-turn-handoff"> - <div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}> - <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} /> - <TextReveal - text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""} - class="session-turn-thinking-heading" - travel={25} - duration={900} - /> + </div> + </Show> + <SessionRetry status={status()} show={active()} /> + <Show when={edited() > 0 && !working()}> + <div data-slot="session-turn-diffs"> + <Collapsible open={open()} onOpenChange={setOpen} variant="ghost"> + <Collapsible.Trigger> + <div data-component="session-turn-diffs-trigger"> + <div data-slot="session-turn-diffs-title"> + <span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span> + <span data-slot="session-turn-diffs-count"> + {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} + </span> + <div data-slot="session-turn-diffs-meta"> + <DiffChanges changes={diffs()} variant="bars" /> + <Collapsible.Arrow /> + </div> + </div> </div> - <Show when={metaOpen()}> - <div - data-slot="session-turn-meta" - data-visible={thinkingVisible() ? "false" : "true"} - data-interrupted={interrupted() ? "" : undefined} - > - <Tooltip - value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - placement="top" - gutter={4} + </Collapsible.Trigger> + <Collapsible.Content> + <Show when={open()}> + <div data-component="session-turn-diffs-content"> + <Accordion + multiple + style={{ "--sticky-accordion-offset": "40px" }} + value={expanded()} + onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > - <IconButton - icon={copied() ? "check" : "copy"} - size="normal" - variant="ghost" - onMouseDown={(event) => event.preventDefault()} - onClick={() => void copyAssistant()} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - </Tooltip> - <Show when={meta()}> - <span - data-slot="session-turn-meta-label" - class="text-12-regular text-text-weak cursor-default" - > - {meta()} - </span> - </Show> + <For each={diffs()}> + {(diff) => { + const active = createMemo(() => expanded().includes(diff.file)) + const [visible, setVisible] = createSignal(false) + + createEffect( + on( + active, + (value) => { + if (!value) { + setVisible(false) + return + } + + requestAnimationFrame(() => { + if (!active()) return + setVisible(true) + }) + }, + { defer: true }, + ), + ) + + return ( + <Accordion.Item value={diff.file}> + <StickyAccordionHeader> + <Accordion.Trigger> + <div data-slot="session-turn-diff-trigger"> + <span data-slot="session-turn-diff-path"> + <Show when={diff.file.includes("/")}> + <span data-slot="session-turn-diff-directory"> + {`\u202A${getDirectory(diff.file)}\u202C`} + </span> + </Show> + <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span> + </span> + <div data-slot="session-turn-diff-meta"> + <span data-slot="session-turn-diff-changes"> + <DiffChanges changes={diff} /> + </span> + <span data-slot="session-turn-diff-chevron"> + <Icon name="chevron-down" size="small" /> + </span> + </div> + </div> + </Accordion.Trigger> + </StickyAccordionHeader> + <Accordion.Content> + <Show when={visible()}> + <div data-slot="session-turn-diff-view" data-scrollable> + <Dynamic + component={fileComponent} + mode="diff" + before={{ name: diff.file, contents: diff.before }} + after={{ name: diff.file, contents: diff.after }} + /> + </div> + </Show> + </Accordion.Content> + </Accordion.Item> + ) + }} + </For> + </Accordion> </div> </Show> - </div> - </GrowBox> + </Collapsible.Content> + </Collapsible> </div> - <GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0"> - {divider(i18n.t("ui.message.interrupted"))} - </GrowBox> - <SessionRetry status={status()} show={active()} /> - <GrowBox - animate={props.animate !== false} - fade - gap={0} - open={showDiffSummary() && !assistantCopyPartID()} - > - {turnDiffSummary()} - </GrowBox> - <Show when={error()}> - <Card variant="error" class="error-card"> - {errorText()} - </Card> - </Show> - </div> - )} + </Show> + <Show when={error()}> + <Card variant="error" class="error-card"> + {errorText()} + </Card> + </Show> + </div> </Show> {props.children} </div> |
