diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a8a41b8ef41..d4624c4d370 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -1,24 +1,32 @@ -import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client" -import type { SessionStatus } from "@opencode-ai/sdk/v2" +import { + AssistantMessage, + FilePart, + type PermissionRequest, + type QuestionRequest, + type SessionStatus, + Message as MessageType, + Part as PartType, + TextPart, + ToolPart, +} from "@opencode-ai/sdk/v2/client" import { useData } from "../context" -import { useFileComponent } from "../context/file" +import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n" import { Binary } from "@opencode-ai/util/binary" -import { getDirectory, getFilename } from "@opencode-ai/util/path" -import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" -import { Dynamic } from "solid-js/web" -import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part" +import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" +import { Message, Part } from "./message-part" +import { Markdown } from "./markdown" +import { IconButton } from "./icon-button" 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 { TextShimmer } from "./text-shimmer" -import { SessionRetry } from "./session-retry" -import { TextReveal } from "./text-reveal" +import { Button } from "./button" +import { Spinner } from "./spinner" +import { Tooltip } from "./tooltip" +import { createStore } from "solid-js/store" +import { DateTime, DurationUnit, Interval } from "luxon" import { createAutoScroll } from "../hooks" -import { useI18n } from "../context/i18n" +import { createResizeObserver } from "@solid-primitives/resize-observer" + +type Translator = (key: UiI18nKey, params?: UiI18nParams) => string function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -73,81 +81,124 @@ function unwrap(message: string) { return message } +function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined { + if (!part) return undefined + + if (part.type === "tool") { + switch (part.tool) { + case "task": + return t("ui.sessionTurn.status.delegating") + case "todowrite": + case "todoread": + return t("ui.sessionTurn.status.planning") + case "read": + return t("ui.sessionTurn.status.gatheringContext") + case "list": + case "grep": + case "glob": + return t("ui.sessionTurn.status.searchingCodebase") + case "webfetch": + return t("ui.sessionTurn.status.searchingWeb") + case "edit": + case "write": + return t("ui.sessionTurn.status.makingEdits") + case "bash": + return t("ui.sessionTurn.status.runningCommands") + default: + return undefined + } + } + if (part.type === "reasoning") { + const text = part.text ?? "" + const match = text.trimStart().match(/^\*\*(.+?)\*\*/) + if (match) return t("ui.sessionTurn.status.thinkingWithTopic", { topic: match[1].trim() }) + return t("ui.sessionTurn.status.thinking") + } + if (part.type === "text") { + return t("ui.sessionTurn.status.gatheringThoughts") + } + return undefined +} + function same(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 isAttachment(part: PartType | undefined) { + if (part?.type !== "file") return false + const mime = (part as FilePart).mime ?? "" + return mime.startsWith("image/") || mime === "application/pdf" +} + function list(value: T[] | undefined | null, fallback: T[]) { if (Array.isArray(value)) return value return fallback } -const hidden = new Set(["todowrite", "todoread"]) - -function partState(part: PartType, showReasoningSummaries: boolean) { - if (part.type === "tool") { - if (hidden.has(part.tool)) return - if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return - return "visible" as const - } - if (part.type === "text") return part.text?.trim() ? ("visible" as const) : undefined - if (part.type === "reasoning") { - if (showReasoningSummaries && part.text?.trim()) return "visible" as const - return - } - if (PART_MAPPING[part.type]) return "visible" as const - return -} +function AssistantMessageItem(props: { + message: AssistantMessage + responsePartId: string | undefined + hideResponsePart: boolean + hideReasoning: boolean + hidden?: () => readonly { messageID: string; callID: string }[] +}) { + const data = useData() + const emptyParts: PartType[] = [] + const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts)) + const lastTextPart = createMemo(() => { + const parts = msgParts() + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i] + if (part?.type === "text") return part as TextPart + } + return undefined + }) -function clean(value: string) { - return value - .replace(/`([^`]+)`/g, "$1") - .replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1") - .replace(/[*_~]+/g, "") - .trim() -} + const filteredParts = createMemo(() => { + let parts = msgParts() -function heading(text: string) { - const markdown = text.replace(/\r\n?/g, "\n") + if (props.hideReasoning) { + parts = parts.filter((part) => part?.type !== "reasoning") + } - const html = markdown.match(/]*>([\s\S]*?)<\/h[1-6]>/i) - if (html?.[1]) { - const value = clean(html[1].replace(/<[^>]+>/g, " ")) - if (value) return value - } + if (props.hideResponsePart) { + const responsePartId = props.responsePartId + if (responsePartId && responsePartId === lastTextPart()?.id) { + parts = parts.filter((part) => part?.id !== responsePartId) + } + } - const atx = markdown.match(/^\s{0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/m) - if (atx?.[1]) { - const value = clean(atx[1]) - if (value) return value - } + const hidden = props.hidden?.() ?? [] + if (hidden.length === 0) return parts - const setext = markdown.match(/^([^\n]+)\n(?:=+|-+)\s*$/m) - if (setext?.[1]) { - const value = clean(setext[1]) - if (value) return value - } + const id = props.message.id + return parts.filter((part) => { + if (part?.type !== "tool") return true + const tool = part as ToolPart + return !hidden.some((h) => h.messageID === id && h.callID === tool.callID) + }) + }) - const strong = markdown.match(/^\s*(?:\*\*|__)(.+?)(?:\*\*|__)\s*$/m) - if (strong?.[1]) { - const value = clean(strong[1]) - if (value) return value - } + return } export function SessionTurn( props: ParentProps<{ sessionID: string + sessionTitle?: string messageID: string + lastUserMessageID?: string + stepsExpanded?: boolean + onStepsExpandedToggle?: () => void + onUserInteracted?: () => void showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean active?: boolean queued?: boolean status?: SessionStatus - onUserInteracted?: () => void classes?: { root?: string content?: string @@ -155,14 +206,16 @@ export function SessionTurn( } }>, ) { - const data = useData() const i18n = useI18n() - const fileComponent = useFileComponent() + const data = useData() const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] + const emptyFiles: FilePart[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyDiffs: FileDiff[] = [] + const emptyPermissions: PermissionRequest[] = [] + const emptyQuestions: QuestionRequest[] = [] + const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) @@ -191,8 +244,22 @@ export function SessionTurn( return msg }) + const lastUserMessageID = createMemo(() => { + if (props.lastUserMessageID) return props.lastUserMessageID + + const messages = allMessages() ?? emptyMessages + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] + if (msg?.role === "user") return msg.id + } + return undefined + }) + + const isLastUserMessage = createMemo(() => props.messageID === lastUserMessageID()) + 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", @@ -202,6 +269,7 @@ export function SessionTurn( 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) @@ -209,7 +277,7 @@ export function SessionTurn( return msg }) - const active = createMemo(() => { + const isActive = createMemo(() => { if (typeof props.active === "boolean") return props.active const msg = message() const parent = pendingUser() @@ -217,13 +285,12 @@ export function SessionTurn( return parent.id === msg.id }) - const queued = createMemo(() => { + const isQueued = 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 + if (!item || !pendingUser()) return false return id > item.id }) @@ -233,35 +300,18 @@ export function SessionTurn( return list(data.store.part?.[msg.id], emptyParts) }) - const compaction = createMemo(() => parts().find((part) => part.type === "compaction")) - - const diffs = createMemo(() => { - const files = message()?.summary?.diffs - if (!files?.length) return emptyDiffs - - const seen = new Set() - return files - .reduceRight((result, diff) => { - if (seen.has(diff.file)) return result - seen.add(diff.file) - result.push(diff) - return result - }, []) - .reverse() + const attachmentParts = createMemo(() => { + const msgParts = parts() + if (msgParts.length === 0) return emptyFiles + return msgParts.filter((part) => isAttachment(part)) as FilePart[] + }) + + const stickyParts = createMemo(() => { + const msgParts = parts() + if (msgParts.length === 0) return emptyParts + if (attachmentParts().length === 0) return msgParts + return msgParts.filter((part) => !isAttachment(part)) }) - const edited = createMemo(() => diffs().length) - const [open, setOpen] = createSignal(false) - const [expanded, setExpanded] = createSignal([]) - - createEffect( - on( - open, - (value, prev) => { - if (!value && prev) setExpanded([]) - }, - { defer: true }, - ), - ) const assistantMessages = createMemo( () => { @@ -285,27 +335,9 @@ export function SessionTurn( { equals: same }, ) - const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) - const error = createMemo( - () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, - ) - const showAssistantCopyPartID = createMemo(() => { - const messages = assistantMessages() + const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - if (!message) continue - - 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" || !part.text?.trim()) continue - return part.id - } - } - - return undefined - }) + const error = createMemo(() => assistantMessages().find((m) => m.error)?.error) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) @@ -313,73 +345,326 @@ export function SessionTurn( return unwrap(String(msg)) }) - 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 lastTextPart = createMemo(() => { + const msgs = assistantMessages() + for (let mi = msgs.length - 1; mi >= 0; mi--) { + const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts) + for (let pi = msgParts.length - 1; pi >= 0; pi--) { + const part = msgParts[pi] + if (part?.type === "text") return part as TextPart + } + } + return undefined }) - const working = createMemo(() => status().type !== "idle" && active()) - const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true) - const assistantCopyPartID = createMemo(() => { - if (working()) return null - return showAssistantCopyPartID() ?? null + const hasSteps = createMemo(() => { + for (const m of assistantMessages()) { + const msgParts = list(data.store.part?.[m.id], emptyParts) + for (const p of msgParts) { + if (p?.type === "tool") return true + } + } + return false }) - const turnDurationMs = createMemo(() => { - const start = message()?.time.created - if (typeof start !== "number") return undefined - - const end = assistantMessages().reduce((max, item) => { - const completed = item.time.completed - if (typeof completed !== "number") return max - if (max === undefined) return completed - return Math.max(max, completed) - }, undefined) - - if (typeof end !== "number") return undefined - if (end < start) return undefined - return end - start + + const permissions = createMemo(() => list((data.store as any).permission?.[props.sessionID], emptyPermissions)) + const nextPermission = createMemo(() => permissions()[0]) + + const questions = createMemo(() => list((data.store as any).question?.[props.sessionID], emptyQuestions)) + const nextQuestion = createMemo(() => questions()[0]) + + const hidden = createMemo(() => { + const out: { messageID: string; callID: string }[] = [] + const perm = nextPermission() + if (perm?.tool) out.push(perm.tool) + const question = nextQuestion() + if (question?.tool) out.push(question.tool) + return out }) - const assistantVisible = createMemo(() => - assistantMessages().reduce((count, message) => { - const parts = list(data.store.part?.[message.id], emptyParts) - return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length - }, 0), - ) - const assistantTailVisible = createMemo(() => - assistantMessages() - .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) - .flatMap((part) => { - if (partState(part, showReasoningSummaries()) !== "visible") return [] - if (part.type === "text") return ["text" as const] - return ["other" as const] - }) - .at(-1), - ) - const reasoningHeading = createMemo(() => - assistantMessages() - .flatMap((message) => list(data.store.part?.[message.id], emptyParts)) - .filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning") - .map((part) => heading(part.text)) - .filter((text): text is string => !!text) - .at(-1), - ) - 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 answeredQuestionParts = createMemo(() => { + if (props.stepsExpanded) return emptyQuestionParts + if (questions().length > 0) return emptyQuestionParts + + const result: { part: ToolPart; message: AssistantMessage }[] = [] + + for (const msg of assistantMessages()) { + const parts = list(data.store.part?.[msg.id], emptyParts) + for (const part of parts) { + if (part?.type !== "tool") continue + const tool = part as ToolPart + if (tool.tool !== "question") continue + // @ts-expect-error metadata may not exist on all tool states + const answers = tool.state?.metadata?.answers + if (answers && answers.length > 0) { + result.push({ part: tool, message: msg }) + } + } + } + + return result }) + const shellModePart = createMemo(() => { + const p = parts() + if (p.length === 0) return + if (!p.every((part) => part?.type === "text" && part?.synthetic)) return + + const msgs = assistantMessages() + if (msgs.length !== 1) return + + const msgParts = list(data.store.part?.[msgs[0].id], emptyParts) + if (msgParts.length !== 1) return + + const assistantPart = msgParts[0] + if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart + }) + + const isShellMode = createMemo(() => !!shellModePart()) + + const hasReasoningParts = createMemo(() => { + for (const m of assistantMessages()) { + const msgParts = data.store.part[m.id] + if (!msgParts) continue + for (const p of msgParts) { + if (p?.type === "reasoning") return true + } + } + return false + }) + + const rawStatus = createMemo(() => { + const msgs = assistantMessages() + let last: PartType | undefined + let currentTask: ToolPart | undefined + + for (let mi = msgs.length - 1; mi >= 0; mi--) { + const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts) + for (let pi = msgParts.length - 1; pi >= 0; pi--) { + const part = msgParts[pi] + if (!part) continue + if (!last) last = part + + if ( + part.type === "tool" && + part.tool === "task" && + part.state && + "metadata" in part.state && + part.state.metadata?.sessionId && + part.state.status === "running" + ) { + currentTask = part as ToolPart + break + } + } + if (currentTask) break + } + + const taskSessionId = + currentTask?.state && "metadata" in currentTask.state + ? (currentTask.state.metadata?.sessionId as string | undefined) + : undefined + + if (taskSessionId) { + const taskMessages = list(data.store.message?.[taskSessionId], emptyMessages) + for (let mi = taskMessages.length - 1; mi >= 0; mi--) { + const msg = taskMessages[mi] + if (!msg || msg.role !== "assistant") continue + + const msgParts = list(data.store.part?.[msg.id], emptyParts) + for (let pi = msgParts.length - 1; pi >= 0; pi--) { + const part = msgParts[pi] + if (part) return computeStatusFromPart(part, i18n.t) + } + } + } + + return computeStatusFromPart(last, i18n.t) + }) + + const status = createMemo(() => { + if (props.status !== undefined) return props.status + if (typeof props.active === "boolean" && !isActive()) return idle + return data.store.session_status[props.sessionID] ?? idle + }) + const working = createMemo(() => status().type !== "idle" && isActive() && !isQueued()) + const retry = createMemo(() => { + // session_status is session-scoped; only show retry on the active turn + if (!isActive()) return + const s = status() + if (s.type !== "retry") return + return s + }) + + const response = createMemo(() => lastTextPart()?.text) + const responsePartId = createMemo(() => lastTextPart()?.id) + const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0) + const hideResponsePart = createMemo(() => !working() && !!responsePartId()) + + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = response() ?? "" + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const [rootRef, setRootRef] = createSignal() + const [stickyRef, setStickyRef] = createSignal() + + const updateStickyHeight = (height: number) => { + const root = rootRef() + if (!root) return + const next = Math.ceil(height) + root.style.setProperty("--session-turn-sticky-height", `${next}px`) + } + + function duration() { + const msg = message() + if (!msg) return "" + const completed = lastAssistantMessage()?.time.completed + const from = DateTime.fromMillis(msg.time.created) + const to = completed ? DateTime.fromMillis(completed) : DateTime.now() + const interval = Interval.fromDateTimes(from, to) + const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] + + const locale = i18n.locale() + const human = interval.toDuration(unit).normalize().reconfigure({ locale }).toHuman({ + notation: "compact", + unitDisplay: "narrow", + compactDisplay: "short", + showZeros: false, + }) + return locale.startsWith("zh") ? human.replaceAll("、", "") : human + } + const autoScroll = createAutoScroll({ working, onUserInteracted: props.onUserInteracted, - overflowAnchor: "dynamic", + overflowAnchor: "auto", + }) + + createResizeObserver( + () => stickyRef(), + ({ height }) => { + updateStickyHeight(height) + }, + ) + + createEffect(() => { + const root = rootRef() + if (!root) return + const sticky = stickyRef() + if (!sticky) { + root.style.setProperty("--session-turn-sticky-height", "0px") + return + } + updateStickyHeight(sticky.getBoundingClientRect().height) + }) + + const [store, setStore] = createStore({ + retrySeconds: 0, + status: rawStatus(), + duration: duration(), + userMessageHovered: false, + showReasoning: props.showReasoningSummaries ?? true, + }) + + createEffect(() => { + const r = retry() + if (!r) { + setStore("retrySeconds", 0) + return + } + const updateSeconds = () => { + const next = r.next + if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000))) + } + updateSeconds() + const timer = setInterval(updateSeconds, 1000) + onCleanup(() => clearInterval(timer)) + }) + + let retryLog = "" + createEffect(() => { + const r = retry() + if (!r) return + const key = `${r.attempt}:${r.next}:${r.message}` + if (key === retryLog) return + retryLog = key + console.warn("[session-turn] retry", { + sessionID: props.sessionID, + messageID: props.messageID, + attempt: r.attempt, + next: r.next, + raw: r.message, + parsed: unwrap(r.message), + }) + }) + + let errorLog = "" + createEffect(() => { + const value = error()?.data?.message + if (value === undefined || value === null) return + const raw = typeof value === "string" ? value : String(value) + if (!raw) return + if (raw === errorLog) return + errorLog = raw + console.warn("[session-turn] assistant-error", { + sessionID: props.sessionID, + messageID: props.messageID, + raw, + parsed: unwrap(raw), + }) + }) + + createEffect(() => { + const update = () => { + setStore("duration", duration()) + } + + update() + + // Only keep ticking while the active (in-progress) turn is running. + if (!working()) return + + const timer = setInterval(update, 1000) + onCleanup(() => clearInterval(timer)) + }) + + let lastStatusChange = Date.now() + let statusTimeout: number | undefined + createEffect(() => { + const newStatus = rawStatus() + if (newStatus === store.status || !newStatus) return + + const timeSinceLastChange = Date.now() - lastStatusChange + if (timeSinceLastChange >= 2500) { + setStore("status", newStatus) + lastStatusChange = Date.now() + if (statusTimeout) { + clearTimeout(statusTimeout) + statusTimeout = undefined + } + } else { + if (statusTimeout) clearTimeout(statusTimeout) + statusTimeout = setTimeout(() => { + setStore("status", rawStatus()) + lastStatusChange = Date.now() + statusTimeout = undefined + }, 2500 - timeSinceLastChange) as unknown as number + } + }) + + onCleanup(() => { + if (!statusTimeout) return + clearTimeout(statusTimeout) }) return ( -
+
-
- -
- - {(part) => ( -
- -
- )} -
- 0}> -
- -
-
- -
- - - + + + + + + 0}> +
+ +
-
-
- - 0 && !working()}> -
- - -
-
- - {i18n.t("ui.sessionReview.change.modified")} - - - {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} - -
- - -
-
+
+ {/* User Message */} +
+ +
+ + {/* Trigger (sticky) */} + +
+
- - - -
- setExpanded(Array.isArray(value) ? value : value ? [value] : [])} - > - - {(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 ( - - - -
- - - - {`\u202A${getDirectory(diff.file)}\u202C`} - - - - {getFilename(diff.file)} - - -
- - - - - - -
-
-
-
- - -
- -
-
-
-
- ) + +
+ {/* Response */} + 0}> +
+ + {(assistantMessage) => ( + + + + {errorText()} + + +
+
+ 0}> +
+ + {({ part, message }) => } + +
+
+ {/* Response */} +
+ {!working() && response() ? response() : ""} +
+ +
+
+
+

{i18n.t("ui.sessionTurn.summary.response")}

+ + + + + + +
+ + e.preventDefault()} + onClick={(event: MouseEvent) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
+
- - - -
- - - - {errorText()} - - + +
+ +
+
+
+ + + + {errorText()} + + + +
)}