message-progress.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. import {
  2. For,
  3. JSXElement,
  4. Match,
  5. Show,
  6. Switch,
  7. ValidComponent,
  8. createEffect,
  9. createMemo,
  10. createSignal,
  11. onCleanup,
  12. } from "solid-js"
  13. import { Part } from "./message-part"
  14. import { Spinner } from "./spinner"
  15. import { useData } from "../context/data"
  16. import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk"
  17. export interface MessageProgressProps {
  18. assistantMessages: () => AssistantMessageType[]
  19. diffComponent: ValidComponent
  20. done?: boolean
  21. }
  22. export function MessageProgress(props: MessageProgressProps) {
  23. const data = useData()
  24. const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
  25. const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.store.part[m.id]))
  26. const done = createMemo(() => props.done ?? false)
  27. const currentTask = createMemo(
  28. () =>
  29. parts().findLast(
  30. (p) =>
  31. p &&
  32. p.type === "tool" &&
  33. p.tool === "task" &&
  34. p.state &&
  35. "metadata" in p.state &&
  36. p.state.metadata &&
  37. p.state.metadata.sessionId &&
  38. p.state.status === "running",
  39. ) as ToolPart,
  40. )
  41. const resolvedParts = createMemo(() => {
  42. let resolved = parts()
  43. const task = currentTask()
  44. if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
  45. const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
  46. (m) => m.role === "assistant",
  47. )
  48. resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? parts()
  49. }
  50. return resolved
  51. })
  52. const eligibleItems = createMemo(() => {
  53. return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[]
  54. })
  55. const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [
  56. <div data-slot="message-progress-item" />,
  57. <div data-slot="message-progress-item" />,
  58. <div data-slot="message-progress-item" />,
  59. ...eligibleItems(),
  60. ...(done()
  61. ? [
  62. <div data-slot="message-progress-item" />,
  63. <div data-slot="message-progress-item" />,
  64. <div data-slot="message-progress-item" />,
  65. ]
  66. : []),
  67. ])
  68. const delay = createMemo(() => (done() ? 220 : 400))
  69. const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
  70. createEffect(() => {
  71. const total = finishedItems().length
  72. if (total > visibleCount()) {
  73. const timer = setTimeout(() => {
  74. setVisibleCount((prev) => prev + 1)
  75. }, delay())
  76. onCleanup(() => clearTimeout(timer))
  77. } else if (total < visibleCount()) {
  78. setVisibleCount(total)
  79. }
  80. })
  81. const translateY = createMemo(() => {
  82. const total = visibleCount()
  83. if (total < 2) return "0px"
  84. return `-${(total - 2) * 40 - 8}px`
  85. })
  86. const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
  87. const rawStatus = createMemo(() => {
  88. const last = lastPart()
  89. if (!last) return undefined
  90. if (last.type === "tool") {
  91. switch (last.tool) {
  92. case "task":
  93. return "Delegating work..."
  94. case "todowrite":
  95. case "todoread":
  96. return "Planning next steps..."
  97. case "read":
  98. return "Gathering context..."
  99. case "list":
  100. case "grep":
  101. case "glob":
  102. return "Searching the codebase..."
  103. case "webfetch":
  104. return "Searching the web..."
  105. case "edit":
  106. case "write":
  107. return "Making edits..."
  108. case "bash":
  109. return "Running commands..."
  110. default:
  111. break
  112. }
  113. } else if (last.type === "reasoning") {
  114. return "Thinking..."
  115. } else if (last.type === "text") {
  116. return "Gathering thoughts..."
  117. }
  118. return undefined
  119. })
  120. const [status, setStatus] = createSignal(rawStatus())
  121. let lastStatusChange = Date.now()
  122. let statusTimeout: number | undefined
  123. createEffect(() => {
  124. const newStatus = rawStatus()
  125. if (newStatus === status() || !newStatus) return
  126. const timeSinceLastChange = Date.now() - lastStatusChange
  127. if (timeSinceLastChange >= 1500) {
  128. setStatus(newStatus)
  129. lastStatusChange = Date.now()
  130. if (statusTimeout) {
  131. clearTimeout(statusTimeout)
  132. statusTimeout = undefined
  133. }
  134. } else {
  135. if (statusTimeout) clearTimeout(statusTimeout)
  136. statusTimeout = setTimeout(() => {
  137. setStatus(rawStatus())
  138. lastStatusChange = Date.now()
  139. statusTimeout = undefined
  140. }, 1000 - timeSinceLastChange) as unknown as number
  141. }
  142. })
  143. return (
  144. <div data-component="message-progress">
  145. <div data-slot="message-progress-status">
  146. <Spinner /> <span data-slot="message-progress-status-text">{status() ?? "Considering next steps..."}</span>
  147. </div>
  148. <Show when={eligibleItems().length > 0}>
  149. <div data-slot="message-progress-list-container">
  150. <div data-slot="message-progress-list" style={{ transform: `translateY(${translateY()})` }}>
  151. <For each={finishedItems()}>
  152. {(part) => (
  153. <Switch>
  154. <Match when={part && typeof part === "object" && "type" in part && part}>
  155. {(p) => {
  156. const part = p() as ToolPart
  157. const message = createMemo(() =>
  158. data.store.message[part.sessionID].find((m) => m.id === part.messageID),
  159. )
  160. return (
  161. <div data-slot="message-progress-item">
  162. <Part
  163. message={message()!}
  164. part={part}
  165. sanitize={sanitizer()}
  166. diffComponent={props.diffComponent}
  167. />
  168. </div>
  169. )
  170. }}
  171. </Match>
  172. <Match when={true}>
  173. <div data-slot="message-progress-item">{part as JSXElement}</div>
  174. </Match>
  175. </Switch>
  176. )}
  177. </For>
  178. </div>
  179. </div>
  180. </Show>
  181. </div>
  182. )
  183. }