summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components/message-progress.tsx
diff options
context:
space:
mode:
authorDax <[email protected]>2025-11-21 20:41:27 -0500
committerGitHub <[email protected]>2025-11-21 20:41:27 -0500
commit49408c00e964093c654ee270d545c0e29857e61f (patch)
tree600b2a94cd082c0af221fd84fd5346cfe8f9ace4 /packages/ui/src/components/message-progress.tsx
parent76192fbcedff945b5d529a5d733deb2488ee9c8a (diff)
downloadopencode-49408c00e964093c654ee270d545c0e29857e61f.tar.gz
opencode-49408c00e964093c654ee270d545c0e29857e61f.zip
enterprise (#4617)
Co-authored-by: GitHub Action <[email protected]> Co-authored-by: Adam <[email protected]>
Diffstat (limited to 'packages/ui/src/components/message-progress.tsx')
-rw-r--r--packages/ui/src/components/message-progress.tsx172
1 files changed, 172 insertions, 0 deletions
diff --git a/packages/ui/src/components/message-progress.tsx b/packages/ui/src/components/message-progress.tsx
new file mode 100644
index 000000000..48a234535
--- /dev/null
+++ b/packages/ui/src/components/message-progress.tsx
@@ -0,0 +1,172 @@
+import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { Part } from "./message-part"
+import { Spinner } from "./spinner"
+import { useData } from "../context/data"
+import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk"
+import "./message-progress.css"
+
+export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
+ const data = useData()
+ const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.part[m.id]))
+ const done = createMemo(() => props.done ?? false)
+ const currentTask = createMemo(
+ () =>
+ parts().findLast(
+ (p) =>
+ p &&
+ p.type === "tool" &&
+ p.tool === "task" &&
+ p.state &&
+ "metadata" in p.state &&
+ p.state.metadata &&
+ p.state.metadata.sessionId &&
+ p.state.status === "running",
+ ) as ToolPart,
+ )
+ const resolvedParts = createMemo(() => {
+ let resolved = parts()
+ const task = currentTask()
+ if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
+ const messages = data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
+ resolved = messages?.flatMap((m) => data.part[m.id]) ?? parts()
+ }
+ return resolved
+ })
+
+ const eligibleItems = createMemo(() => {
+ return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[]
+ })
+ const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [
+ <div data-slot="message-progress-item" />,
+ <div data-slot="message-progress-item" />,
+ <div data-slot="message-progress-item" />,
+ ...eligibleItems(),
+ ...(done()
+ ? [
+ <div data-slot="message-progress-item" />,
+ <div data-slot="message-progress-item" />,
+ <div data-slot="message-progress-item" />,
+ ]
+ : []),
+ ])
+
+ const delay = createMemo(() => (done() ? 220 : 400))
+ const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
+
+ createEffect(() => {
+ const total = finishedItems().length
+ if (total > visibleCount()) {
+ const timer = setTimeout(() => {
+ setVisibleCount((prev) => prev + 1)
+ }, delay())
+ onCleanup(() => clearTimeout(timer))
+ } else if (total < visibleCount()) {
+ setVisibleCount(total)
+ }
+ })
+
+ const translateY = createMemo(() => {
+ const total = visibleCount()
+ if (total < 2) return "0px"
+ return `-${(total - 2) * 40 - 8}px`
+ })
+
+ const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
+ const rawStatus = createMemo(() => {
+ const last = lastPart()
+ if (!last) return undefined
+
+ if (last.type === "tool") {
+ switch (last.tool) {
+ case "task":
+ return "Delegating work..."
+ case "todowrite":
+ case "todoread":
+ return "Planning next steps..."
+ case "read":
+ return "Gathering context..."
+ case "list":
+ case "grep":
+ case "glob":
+ return "Searching the codebase..."
+ case "webfetch":
+ return "Searching the web..."
+ case "edit":
+ case "write":
+ return "Making edits..."
+ case "bash":
+ return "Running commands..."
+ default:
+ break
+ }
+ } else if (last.type === "reasoning") {
+ return "Thinking..."
+ } else if (last.type === "text") {
+ return "Gathering thoughts..."
+ }
+ return undefined
+ })
+
+ const [status, setStatus] = createSignal(rawStatus())
+ let lastStatusChange = Date.now()
+ let statusTimeout: number | undefined
+
+ createEffect(() => {
+ const newStatus = rawStatus()
+ if (newStatus === status() || !newStatus) return
+
+ const timeSinceLastChange = Date.now() - lastStatusChange
+
+ if (timeSinceLastChange >= 1500) {
+ setStatus(newStatus)
+ lastStatusChange = Date.now()
+ if (statusTimeout) {
+ clearTimeout(statusTimeout)
+ statusTimeout = undefined
+ }
+ } else {
+ if (statusTimeout) clearTimeout(statusTimeout)
+ statusTimeout = setTimeout(() => {
+ setStatus(rawStatus())
+ lastStatusChange = Date.now()
+ statusTimeout = undefined
+ }, 1000 - timeSinceLastChange) as unknown as number
+ }
+ })
+
+ return (
+ <div data-component="message-progress">
+ <div data-slot="message-progress-status">
+ <Spinner /> <span data-slot="message-progress-status-text">{status() ?? "Considering next steps..."}</span>
+ </div>
+ <Show when={eligibleItems().length > 0}>
+ <div data-slot="message-progress-list-container">
+ <div data-slot="message-progress-list" style={{ transform: `translateY(${translateY()})` }}>
+ <For each={finishedItems()}>
+ {(part) => (
+ <Switch>
+ <Match when={part && typeof part === "object" && "type" in part && part}>
+ {(p) => {
+ const part = p() as ToolPart
+ const message = createMemo(() =>
+ data.message[part.sessionID].find((m) => m.id === part.messageID),
+ )
+ return (
+ <div data-slot="message-progress-item">
+ <Part message={message()!} part={part} />
+ </div>
+ )
+ }}
+ </Match>
+ <Match when={true}>
+ <div data-slot="message-progress-item">{part as JSXElement}</div>
+ </Match>
+ </Switch>
+ )}
+ </For>
+ </div>
+ </div>
+ </Show>
+ </div>
+ )
+}