session-turn.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import { AssistantMessage } from "@opencode-ai/sdk/v2"
  2. import { useData } from "../context"
  3. import { useDiffComponent } from "../context/diff"
  4. import { getDirectory, getFilename } from "@opencode-ai/util/path"
  5. import { checksum } from "@opencode-ai/util/encode"
  6. import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
  7. import { DiffChanges } from "./diff-changes"
  8. import { Typewriter } from "./typewriter"
  9. import { Message } from "./message-part"
  10. import { Markdown } from "./markdown"
  11. import { Accordion } from "./accordion"
  12. import { StickyAccordionHeader } from "./sticky-accordion-header"
  13. import { FileIcon } from "./file-icon"
  14. import { Icon } from "./icon"
  15. import { Card } from "./card"
  16. import { MessageProgress } from "./message-progress"
  17. import { Collapsible } from "./collapsible"
  18. import { Dynamic } from "solid-js/web"
  19. // Track animation state per message ID - persists across re-renders
  20. // "empty" = first saw with no value (should animate when value arrives)
  21. // "animating" = currently animating (keep returning true)
  22. // "done" = already animated or first saw with value (never animate)
  23. const titleAnimationState = new Map<string, "empty" | "animating" | "done">()
  24. const summaryAnimationState = new Map<string, "empty" | "animating" | "done">()
  25. export function SessionTurn(
  26. props: ParentProps<{
  27. sessionID: string
  28. messageID: string
  29. classes?: {
  30. root?: string
  31. content?: string
  32. container?: string
  33. }
  34. }>,
  35. ) {
  36. const data = useData()
  37. const diffComponent = useDiffComponent()
  38. const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
  39. const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
  40. const userMessages = createMemo(() =>
  41. messages()
  42. .filter((m) => m.role === "user")
  43. .sort((a, b) => b.id.localeCompare(a.id)),
  44. )
  45. const lastUserMessage = createMemo(() => {
  46. return userMessages()?.at(0)
  47. })
  48. const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
  49. const status = createMemo(
  50. () =>
  51. data.store.session_status[props.sessionID] ?? {
  52. type: "idle",
  53. },
  54. )
  55. const working = createMemo(() => status()?.type !== "idle")
  56. return (
  57. <div data-component="session-turn" class={props.classes?.root}>
  58. <div data-slot="session-turn-content" class={props.classes?.content}>
  59. <Show when={message()}>
  60. {(msg) => {
  61. const [detailsExpanded, setDetailsExpanded] = createSignal(false)
  62. // Animation logic: only animate if we witness the value transition from empty to non-empty
  63. // Track in module-level Maps keyed by message ID so it persists across re-renders
  64. // Initialize animation state for current message (reactive - runs when msg().id changes)
  65. createEffect(() => {
  66. const id = msg().id
  67. if (!titleAnimationState.has(id)) {
  68. titleAnimationState.set(id, msg().summary?.title ? "done" : "empty")
  69. }
  70. if (!summaryAnimationState.has(id)) {
  71. const assistantMsgs = messages()?.filter(
  72. (m) => m.role === "assistant" && m.parentID == id,
  73. ) as AssistantMessage[]
  74. const parts = assistantMsgs?.flatMap((m) => data.store.part[m.id])
  75. const lastText = parts?.filter((p) => p?.type === "text")?.at(-1)
  76. const summaryValue = msg().summary?.body ?? lastText?.text
  77. summaryAnimationState.set(id, summaryValue ? "done" : "empty")
  78. }
  79. // When message changes or component unmounts, mark any "animating" states as "done"
  80. onCleanup(() => {
  81. if (titleAnimationState.get(id) === "animating") {
  82. titleAnimationState.set(id, "done")
  83. }
  84. if (summaryAnimationState.get(id) === "animating") {
  85. summaryAnimationState.set(id, "done")
  86. }
  87. })
  88. })
  89. const assistantMessages = createMemo(() => {
  90. return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
  91. })
  92. const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
  93. const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
  94. const parts = createMemo(() => data.store.part[msg().id])
  95. const lastTextPart = createMemo(() =>
  96. assistantMessageParts()
  97. .filter((p) => p?.type === "text")
  98. ?.at(-1),
  99. )
  100. const hasToolPart = createMemo(() => assistantMessageParts().some((p) => p?.type === "tool"))
  101. const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working())
  102. const initialCompleted = !(msg().id === lastUserMessage()?.id && working())
  103. const [completed, setCompleted] = createSignal(initialCompleted)
  104. const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text)
  105. const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0)
  106. // Should animate: state is "empty" AND value now exists, or state is "animating"
  107. // Transition: empty -> animating -> done (done happens on cleanup)
  108. const animateTitle = createMemo(() => {
  109. const id = msg().id
  110. const state = titleAnimationState.get(id)
  111. const title = msg().summary?.title
  112. if (state === "animating") {
  113. return true
  114. }
  115. if (state === "empty" && title) {
  116. titleAnimationState.set(id, "animating")
  117. return true
  118. }
  119. return false
  120. })
  121. const animateSummary = createMemo(() => {
  122. const id = msg().id
  123. const state = summaryAnimationState.get(id)
  124. const value = summary()
  125. if (state === "animating") {
  126. return true
  127. }
  128. if (state === "empty" && value) {
  129. summaryAnimationState.set(id, "animating")
  130. return true
  131. }
  132. return false
  133. })
  134. createEffect(() => {
  135. const done = !messageWorking()
  136. setTimeout(() => setCompleted(done), 1200)
  137. })
  138. return (
  139. <div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}>
  140. {/* Title */}
  141. <div data-slot="session-turn-message-header">
  142. <div data-slot="session-turn-message-title">
  143. <Show
  144. when={!animateTitle()}
  145. fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
  146. >
  147. <h1>{msg().summary?.title}</h1>
  148. </Show>
  149. </div>
  150. </div>
  151. <div data-slot="session-turn-message-content">
  152. <Message message={msg()} parts={parts()} sanitize={sanitizer()} />
  153. </div>
  154. {/* Summary */}
  155. <Show when={completed()}>
  156. <div data-slot="session-turn-summary-section">
  157. <div data-slot="session-turn-summary-header">
  158. <h2 data-slot="session-turn-summary-title">
  159. <Switch>
  160. <Match when={msg().summary?.diffs?.length}>Summary</Match>
  161. <Match when={true}>Response</Match>
  162. </Switch>
  163. </h2>
  164. <Show when={summary()}>
  165. {(summary) => (
  166. <Markdown
  167. data-slot="session-turn-markdown"
  168. data-diffs={!!msg().summary?.diffs?.length}
  169. data-fade={!msg().summary?.diffs?.length && animateSummary()}
  170. text={summary()}
  171. />
  172. )}
  173. </Show>
  174. </div>
  175. <Accordion data-slot="session-turn-accordion" multiple>
  176. <For each={msg().summary?.diffs ?? []}>
  177. {(diff) => (
  178. <Accordion.Item value={diff.file}>
  179. <StickyAccordionHeader>
  180. <Accordion.Trigger>
  181. <div data-slot="session-turn-accordion-trigger-content">
  182. <div data-slot="session-turn-file-info">
  183. <FileIcon
  184. node={{ path: diff.file, type: "file" }}
  185. data-slot="session-turn-file-icon"
  186. />
  187. <div data-slot="session-turn-file-path">
  188. <Show when={diff.file.includes("/")}>
  189. <span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
  190. </Show>
  191. <span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
  192. </div>
  193. </div>
  194. <div data-slot="session-turn-accordion-actions">
  195. <DiffChanges changes={diff} />
  196. <Icon name="chevron-grabber-vertical" size="small" />
  197. </div>
  198. </div>
  199. </Accordion.Trigger>
  200. </StickyAccordionHeader>
  201. <Accordion.Content data-slot="session-turn-accordion-content">
  202. <Dynamic
  203. component={diffComponent}
  204. before={{
  205. name: diff.file!,
  206. contents: diff.before!,
  207. cacheKey: checksum(diff.before!),
  208. }}
  209. after={{
  210. name: diff.file!,
  211. contents: diff.after!,
  212. cacheKey: checksum(diff.after!),
  213. }}
  214. />
  215. </Accordion.Content>
  216. </Accordion.Item>
  217. )}
  218. </For>
  219. </Accordion>
  220. </div>
  221. </Show>
  222. <Show when={error() && !detailsExpanded()}>
  223. <Card variant="error" class="error-card">
  224. {error()?.data?.message as string}
  225. </Card>
  226. </Show>
  227. {/* Response */}
  228. <div data-slot="session-turn-response-section">
  229. <Switch>
  230. <Match when={!completed()}>
  231. <MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
  232. </Match>
  233. <Match when={completed() && hasToolPart()}>
  234. <Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
  235. <Collapsible.Trigger>
  236. <div data-slot="session-turn-collapsible-trigger-content">
  237. <div data-slot="session-turn-details-text">
  238. <Switch>
  239. <Match when={detailsExpanded()}>Hide details</Match>
  240. <Match when={!detailsExpanded()}>Show details</Match>
  241. </Switch>
  242. </div>
  243. <Collapsible.Arrow />
  244. </div>
  245. </Collapsible.Trigger>
  246. <Collapsible.Content>
  247. <div data-slot="session-turn-collapsible-content-inner">
  248. <For each={assistantMessages()}>
  249. {(assistantMessage) => {
  250. const parts = createMemo(() => data.store.part[assistantMessage.id])
  251. const last = createMemo(() =>
  252. parts()
  253. .filter((p) => p?.type === "text")
  254. .at(-1),
  255. )
  256. if (lastTextPartShown() && lastTextPart()?.id === last()?.id) {
  257. return (
  258. <Message
  259. message={assistantMessage}
  260. parts={parts().filter((p) => p?.id !== last()?.id)}
  261. sanitize={sanitizer()}
  262. />
  263. )
  264. }
  265. return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
  266. }}
  267. </For>
  268. <Show when={error()}>
  269. <Card variant="error" class="error-card">
  270. {error()?.data?.message as string}
  271. </Card>
  272. </Show>
  273. </div>
  274. </Collapsible.Content>
  275. </Collapsible>
  276. </Match>
  277. </Switch>
  278. </div>
  279. </div>
  280. )
  281. }}
  282. </Show>
  283. {props.children}
  284. </div>
  285. </div>
  286. )
  287. }