session-timeline.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  1. import { Icon, Tooltip } from "@opencode-ai/ui"
  2. import { Collapsible } from "@/ui"
  3. import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
  4. import { DateTime } from "luxon"
  5. import {
  6. createSignal,
  7. For,
  8. Match,
  9. splitProps,
  10. Switch,
  11. type ComponentProps,
  12. type ParentProps,
  13. createEffect,
  14. createMemo,
  15. Show,
  16. } from "solid-js"
  17. import { getFilename } from "@/utils"
  18. import { Markdown } from "./markdown"
  19. import { Code } from "./code"
  20. import { createElementSize } from "@solid-primitives/resize-observer"
  21. import { createScrollPosition } from "@solid-primitives/scroll"
  22. import { ProgressCircle } from "./progress-circle"
  23. import { pipe, sumBy } from "remeda"
  24. import { useSync } from "@/context/sync"
  25. import { useLocal } from "@/context/local"
  26. function Part(props: ParentProps & ComponentProps<"div">) {
  27. const [local, others] = splitProps(props, ["class", "classList", "children"])
  28. return (
  29. <div
  30. classList={{
  31. ...(local.classList ?? {}),
  32. "h-6 flex items-center": true,
  33. [local.class ?? ""]: !!local.class,
  34. }}
  35. {...others}
  36. >
  37. <p class="text-12-medium text-left">{local.children}</p>
  38. </div>
  39. )
  40. }
  41. function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps<typeof Collapsible>) {
  42. return (
  43. <Collapsible {...props}>
  44. <Collapsible.Trigger class="peer/collapsible">
  45. <Part>{props.title}</Part>
  46. </Collapsible.Trigger>
  47. <Collapsible.Content>
  48. <p class="flex-auto min-w-0 text-pretty">
  49. <span class="text-12-medium text-text-weak break-words">{props.children}</span>
  50. </p>
  51. </Collapsible.Content>
  52. </Collapsible>
  53. )
  54. }
  55. function ReadToolPart(props: { part: ToolPart }) {
  56. const sync = useSync()
  57. const local = useLocal()
  58. return (
  59. <Switch>
  60. <Match when={props.part.state.status === "pending"}>
  61. <Part>Reading file...</Part>
  62. </Match>
  63. <Match when={props.part.state.status === "completed" && props.part.state}>
  64. {(state) => {
  65. const path = state().input["filePath"] as string
  66. return (
  67. <Part onClick={() => local.file.open(path)}>
  68. <span class="">Read</span> {getFilename(path)}
  69. </Part>
  70. )
  71. }}
  72. </Match>
  73. <Match when={props.part.state.status === "error" && props.part.state}>
  74. {(state) => (
  75. <div>
  76. <Part>
  77. <span class="">Read</span> {getFilename(state().input["filePath"] as string)}
  78. </Part>
  79. <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
  80. </div>
  81. )}
  82. </Match>
  83. </Switch>
  84. )
  85. }
  86. function EditToolPart(props: { part: ToolPart }) {
  87. const sync = useSync()
  88. return (
  89. <Switch>
  90. <Match when={props.part.state.status === "pending"}>
  91. <Part>Preparing edit...</Part>
  92. </Match>
  93. <Match when={props.part.state.status === "completed" && props.part.state}>
  94. {(state) => (
  95. <CollapsiblePart
  96. title={
  97. <>
  98. <span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
  99. </>
  100. }
  101. >
  102. <Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} />
  103. </CollapsiblePart>
  104. )}
  105. </Match>
  106. <Match when={props.part.state.status === "error" && props.part.state}>
  107. {(state) => (
  108. <CollapsiblePart
  109. title={
  110. <>
  111. <span class="">Edit</span> {getFilename(state().input["filePath"] as string)}
  112. </>
  113. }
  114. >
  115. <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
  116. </CollapsiblePart>
  117. )}
  118. </Match>
  119. </Switch>
  120. )
  121. }
  122. function WriteToolPart(props: { part: ToolPart }) {
  123. const sync = useSync()
  124. return (
  125. <Switch>
  126. <Match when={props.part.state.status === "pending"}>
  127. <Part>Preparing write...</Part>
  128. </Match>
  129. <Match when={props.part.state.status === "completed" && props.part.state}>
  130. {(state) => (
  131. <CollapsiblePart
  132. title={
  133. <>
  134. <span class="">Write</span> {getFilename(state().input["filePath"] as string)}
  135. </>
  136. }
  137. >
  138. <div class="p-2 bg-background-panel rounded-md border border-border-subtle"></div>
  139. </CollapsiblePart>
  140. )}
  141. </Match>
  142. <Match when={props.part.state.status === "error" && props.part.state}>
  143. {(state) => (
  144. <div>
  145. <Part>
  146. <span class="">Write</span> {getFilename(state().input["filePath"] as string)}
  147. </Part>
  148. <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
  149. </div>
  150. )}
  151. </Match>
  152. </Switch>
  153. )
  154. }
  155. function BashToolPart(props: { part: ToolPart }) {
  156. const sync = useSync()
  157. return (
  158. <Switch>
  159. <Match when={props.part.state.status === "pending"}>
  160. <Part>Writing shell command...</Part>
  161. </Match>
  162. <Match when={props.part.state.status === "completed" && props.part.state}>
  163. {(state) => (
  164. <CollapsiblePart
  165. defaultOpen
  166. title={
  167. <>
  168. <span class="">Run command:</span> {state().input["command"]}
  169. </>
  170. }
  171. >
  172. <Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
  173. </CollapsiblePart>
  174. )}
  175. </Match>
  176. <Match when={props.part.state.status === "error" && props.part.state}>
  177. {(state) => (
  178. <CollapsiblePart
  179. title={
  180. <>
  181. <span class="">Shell</span> {state().input["command"]}
  182. </>
  183. }
  184. >
  185. <div class="text-icon-critical-active">{sync.sanitize(state().error)}</div>
  186. </CollapsiblePart>
  187. )}
  188. </Match>
  189. </Switch>
  190. )
  191. }
  192. function ToolPart(props: { part: ToolPart }) {
  193. // read
  194. // edit
  195. // write
  196. // bash
  197. // ls
  198. // glob
  199. // grep
  200. // todowrite
  201. // todoread
  202. // webfetch
  203. // websearch
  204. // patch
  205. // task
  206. return (
  207. <div class="min-w-0 flex-auto text-12-medium">
  208. <Switch
  209. fallback={
  210. <span>
  211. {props.part.type}:{props.part.tool}
  212. </span>
  213. }
  214. >
  215. <Match when={props.part.tool === "read"}>
  216. <ReadToolPart part={props.part} />
  217. </Match>
  218. <Match when={props.part.tool === "edit"}>
  219. <EditToolPart part={props.part} />
  220. </Match>
  221. <Match when={props.part.tool === "write"}>
  222. <WriteToolPart part={props.part} />
  223. </Match>
  224. <Match when={props.part.tool === "bash"}>
  225. <BashToolPart part={props.part} />
  226. </Match>
  227. </Switch>
  228. </div>
  229. )
  230. }
  231. export default function SessionTimeline(props: { session: string; class?: string }) {
  232. const sync = useSync()
  233. const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined)
  234. const [root, setRoot] = createSignal<HTMLDivElement | undefined>(undefined)
  235. const [tail, setTail] = createSignal(true)
  236. const size = createElementSize(root)
  237. const scroll = createScrollPosition(scrollElement)
  238. const valid = (part: Part) => {
  239. if (!part) return false
  240. switch (part.type) {
  241. case "step-start":
  242. case "step-finish":
  243. case "file":
  244. case "patch":
  245. return false
  246. case "text":
  247. return !part.synthetic && part.text.trim()
  248. case "reasoning":
  249. return part.text.trim()
  250. case "tool":
  251. switch (part.tool) {
  252. case "todoread":
  253. case "todowrite":
  254. case "list":
  255. case "grep":
  256. return false
  257. }
  258. return true
  259. default:
  260. return true
  261. }
  262. }
  263. const hasValidParts = (message: Message) => {
  264. return sync.data.part[message.id]?.filter(valid).length > 0
  265. }
  266. const hasTextPart = (message: Message) => {
  267. return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
  268. }
  269. const session = createMemo(() => sync.session.get(props.session))
  270. const messages = createMemo(() => sync.data.message[props.session] ?? [])
  271. const messagesWithValidParts = createMemo(() => sync.data.message[props.session]?.filter(hasValidParts) ?? [])
  272. const working = createMemo(() => {
  273. const last = messages()[messages().length - 1]
  274. if (!last) return false
  275. if (last.role === "user") return true
  276. return !last.time.completed
  277. })
  278. const cost = createMemo(() => {
  279. const total = pipe(
  280. messages(),
  281. sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
  282. )
  283. return new Intl.NumberFormat("en-US", {
  284. style: "currency",
  285. currency: "USD",
  286. }).format(total)
  287. })
  288. const last = createMemo(() => {
  289. return messages().findLast((x) => x.role === "assistant") as AssistantMessage
  290. })
  291. const model = createMemo(() => {
  292. if (!last()) return
  293. const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
  294. return model
  295. })
  296. const tokens = createMemo(() => {
  297. if (!last()) return
  298. const tokens = last().tokens
  299. const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
  300. return new Intl.NumberFormat("en-US", {
  301. notation: "compact",
  302. compactDisplay: "short",
  303. }).format(total)
  304. })
  305. const context = createMemo(() => {
  306. if (!last()) return
  307. if (!model()?.limit.context) return 0
  308. const tokens = last().tokens
  309. const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
  310. return Math.round((total / model()!.limit.context) * 100)
  311. })
  312. const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => {
  313. let p = el?.parentElement
  314. while (p && p !== document.body) {
  315. const s = getComputedStyle(p)
  316. if (s.overflowY === "auto" || s.overflowY === "scroll") return p
  317. p = p.parentElement
  318. }
  319. return undefined
  320. }
  321. createEffect(() => {
  322. if (!root()) return
  323. setScrollElement(getScrollParent(root()!))
  324. })
  325. const scrollToBottom = () => {
  326. const element = scrollElement()
  327. if (!element) return
  328. element.scrollTop = element.scrollHeight
  329. }
  330. createEffect(() => {
  331. size.height
  332. if (tail()) scrollToBottom()
  333. })
  334. createEffect(() => {
  335. if (working()) {
  336. setTail(true)
  337. scrollToBottom()
  338. }
  339. })
  340. let lastScrollY = 0
  341. createEffect(() => {
  342. if (scroll.y < lastScrollY) {
  343. setTail(false)
  344. }
  345. lastScrollY = scroll.y
  346. })
  347. const duration = (part: Part) => {
  348. switch (part.type) {
  349. default:
  350. if (
  351. "time" in part &&
  352. part.time &&
  353. "start" in part.time &&
  354. part.time.start &&
  355. "end" in part.time &&
  356. part.time.end
  357. ) {
  358. const start = DateTime.fromMillis(part.time.start)
  359. const end = DateTime.fromMillis(part.time.end)
  360. return end.diff(start).toFormat("s")
  361. }
  362. return ""
  363. }
  364. }
  365. return (
  366. <div
  367. ref={setRoot}
  368. classList={{
  369. "select-text flex flex-col text-text-weak": true,
  370. [props.class ?? ""]: !!props.class,
  371. }}
  372. >
  373. <div class="flex justify-end items-center self-stretch">
  374. <div class="flex items-center gap-6">
  375. <Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
  376. <Show when={context()}>
  377. <ProgressCircle percentage={context()!} />
  378. </Show>
  379. <div class="text-14-regular text-text-weak text-right">{context()}%</div>
  380. </Tooltip>
  381. <div class="text-14-regular text-text-strong text-right">{cost()}</div>
  382. </div>
  383. </div>
  384. <ul role="list" class="flex flex-col items-start self-stretch">
  385. <For each={messagesWithValidParts()}>
  386. {(message) => (
  387. <div
  388. classList={{
  389. "flex flex-col gap-1 justify-center items-start self-stretch": true,
  390. "mt-6": hasTextPart(message),
  391. }}
  392. >
  393. <For each={sync.data.part[message.id]?.filter(valid) ?? []}>
  394. {(part) => (
  395. <li class="group/li">
  396. <Switch fallback={<div class="">{part.type}</div>}>
  397. <Match when={part.type === "text" && part}>
  398. {(part) => (
  399. <Switch>
  400. <Match when={message.role === "user"}>
  401. <div class="w-fit flex items-center px-3 py-1 rounded-md bg-surface-weak">
  402. <span class="text-14-regular text-text-strong whitespace-pre-wrap break-words">
  403. {part().text}
  404. </span>
  405. </div>
  406. </Match>
  407. <Match when={message.role === "assistant"}>
  408. <Markdown text={sync.sanitize(part().text)} />
  409. </Match>
  410. </Switch>
  411. )}
  412. </Match>
  413. <Match when={part.type === "reasoning" && part}>
  414. {(part) => (
  415. <CollapsiblePart
  416. title={
  417. <Switch fallback={<span class="text-text-weak">Thinking</span>}>
  418. <Match when={part().time.end}>
  419. <span class="text-12-medium text-text-weak">Thought</span> for {duration(part())}s
  420. </Match>
  421. </Switch>
  422. }
  423. >
  424. <Markdown text={part().text} />
  425. </CollapsiblePart>
  426. )}
  427. </Match>
  428. <Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
  429. </Switch>
  430. </li>
  431. )}
  432. </For>
  433. </div>
  434. )}
  435. </For>
  436. </ul>
  437. <Show when={false}>
  438. <Collapsible defaultOpen={false}>
  439. <Collapsible.Trigger>
  440. <div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
  441. <Icon name="file-code" />
  442. <span>Raw Session Data</span>
  443. <Collapsible.Arrow class="text-text-muted" />
  444. </div>
  445. </Collapsible.Trigger>
  446. <Collapsible.Content class="mt-5">
  447. <ul role="list" class="space-y-2">
  448. <li>
  449. <Collapsible>
  450. <Collapsible.Trigger>
  451. <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
  452. <Icon name="file-code" />
  453. <span>session</span>
  454. <Collapsible.Arrow class="text-text-muted" />
  455. </div>
  456. </Collapsible.Trigger>
  457. <Collapsible.Content>
  458. <Code path="session.json" code={JSON.stringify(session(), null, 2)} />
  459. </Collapsible.Content>
  460. </Collapsible>
  461. </li>
  462. <For each={messages()}>
  463. {(message) => (
  464. <>
  465. <li>
  466. <Collapsible>
  467. <Collapsible.Trigger>
  468. <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
  469. <Icon name="file-code" />
  470. <span>{message.role === "user" ? "user" : "assistant"}</span>
  471. <Collapsible.Arrow class="text-text-muted" />
  472. </div>
  473. </Collapsible.Trigger>
  474. <Collapsible.Content>
  475. <Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} />
  476. </Collapsible.Content>
  477. </Collapsible>
  478. </li>
  479. <For each={sync.data.part[message.id]}>
  480. {(part) => (
  481. <li>
  482. <Collapsible>
  483. <Collapsible.Trigger>
  484. <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
  485. <Icon name="file-code" />
  486. <span>{part.type}</span>
  487. <Collapsible.Arrow class="text-text-muted" />
  488. </div>
  489. </Collapsible.Trigger>
  490. <Collapsible.Content>
  491. <Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} />
  492. </Collapsible.Content>
  493. </Collapsible>
  494. </li>
  495. )}
  496. </For>
  497. </>
  498. )}
  499. </For>
  500. </ul>
  501. </Collapsible.Content>
  502. </Collapsible>
  503. </Show>
  504. </div>
  505. )
  506. }