Share.tsx 71 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819
  1. import { type JSX } from "solid-js"
  2. import {
  3. For,
  4. Show,
  5. Match,
  6. Switch,
  7. onMount,
  8. onCleanup,
  9. splitProps,
  10. createMemo,
  11. createEffect,
  12. createSignal,
  13. } from "solid-js"
  14. import { DateTime } from "luxon"
  15. import { createStore, reconcile } from "solid-js/store"
  16. import type { Diagnostic } from "vscode-languageserver-types"
  17. import { IconOpenAI, IconGemini, IconAnthropic } from "./icons/custom"
  18. import {
  19. IconFolder,
  20. IconCpuChip,
  21. IconSparkles,
  22. IconGlobeAlt,
  23. IconDocument,
  24. IconQueueList,
  25. IconUserCircle,
  26. IconChevronDown,
  27. IconCommandLine,
  28. IconChevronRight,
  29. IconDocumentPlus,
  30. IconPencilSquare,
  31. IconRectangleStack,
  32. IconMagnifyingGlass,
  33. IconWrenchScrewdriver,
  34. IconDocumentMagnifyingGlass,
  35. } from "./icons"
  36. import DiffView from "./DiffView"
  37. import CodeBlock from "./CodeBlock"
  38. import MarkdownView from "./MarkdownView"
  39. import styles from "./share.module.css"
  40. import type { Message } from "opencode/session/message"
  41. import type { Session } from "opencode/session/index"
  42. const MIN_DURATION = 2
  43. type Status =
  44. | "disconnected"
  45. | "connecting"
  46. | "connected"
  47. | "error"
  48. | "reconnecting"
  49. type TodoStatus = "pending" | "in_progress" | "completed"
  50. interface Todo {
  51. id: string
  52. content: string
  53. status: TodoStatus
  54. priority: "low" | "medium" | "high"
  55. }
  56. function sortTodosByStatus(todos: Todo[]) {
  57. const statusPriority: Record<TodoStatus, number> = {
  58. in_progress: 0,
  59. pending: 1,
  60. completed: 2,
  61. }
  62. return todos
  63. .slice()
  64. .sort((a, b) => statusPriority[a.status] - statusPriority[b.status])
  65. }
  66. function scrollToAnchor(id: string) {
  67. const el = document.getElementById(id)
  68. if (!el) return
  69. el.scrollIntoView({ behavior: "smooth" })
  70. }
  71. function stripWorkingDirectory(filePath: string, workingDir?: string) {
  72. if (workingDir === undefined) return filePath
  73. const prefix = workingDir.endsWith('/') ? workingDir : workingDir + '/'
  74. if (filePath === workingDir) {
  75. return ''
  76. }
  77. if (filePath.startsWith(prefix)) {
  78. return filePath.slice(prefix.length)
  79. }
  80. return filePath
  81. }
  82. function getFileType(path: string) {
  83. return path.split(".").pop()
  84. }
  85. function formatDuration(ms: number): string {
  86. const ONE_SECOND = 1000
  87. const ONE_MINUTE = 60 * ONE_SECOND
  88. if (ms >= ONE_MINUTE) {
  89. const minutes = Math.floor(ms / ONE_MINUTE)
  90. return minutes === 1 ? `1min` : `${minutes}mins`
  91. }
  92. if (ms >= ONE_SECOND) {
  93. const seconds = Math.floor(ms / ONE_SECOND)
  94. return `${seconds}s`
  95. }
  96. return `${ms}ms`
  97. }
  98. // Converts nested objects/arrays into [path, value] pairs.
  99. // E.g. {a:{b:{c:1}}, d:[{e:2}, 3]} => [["a.b.c",1], ["d[0].e",2], ["d[1]",3]]
  100. function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> {
  101. const entries: Array<[string, any]> = []
  102. for (const [key, value] of Object.entries(obj)) {
  103. const path = prefix ? `${prefix}.${key}` : key
  104. if (value !== null && typeof value === "object") {
  105. if (Array.isArray(value)) {
  106. value.forEach((item, index) => {
  107. const arrayPath = `${path}[${index}]`
  108. if (item !== null && typeof item === "object") {
  109. entries.push(...flattenToolArgs(item, arrayPath))
  110. } else {
  111. entries.push([arrayPath, item])
  112. }
  113. })
  114. } else {
  115. entries.push(...flattenToolArgs(value, path))
  116. }
  117. } else {
  118. entries.push([path, value])
  119. }
  120. }
  121. return entries
  122. }
  123. function formatErrorString(error: string): JSX.Element {
  124. const errorMarker = "Error: "
  125. const startsWithError = error.startsWith(errorMarker)
  126. return startsWithError ? (
  127. <pre>
  128. <span data-color="red" data-marker="label" data-separator>
  129. Error
  130. </span>
  131. <span>{error.slice(errorMarker.length)}</span>
  132. </pre>
  133. ) : (
  134. <pre><span data-color="dimmed">{error}</span></pre>
  135. )
  136. }
  137. function getDiagnostics(
  138. diagnosticsByFile: Record<string, Diagnostic[]>,
  139. currentFile: string
  140. ): JSX.Element[] {
  141. // Return a flat array of error diagnostics, in the format:
  142. // "Error [65:20] Property 'x' does not exist on type 'Y'"
  143. const result: JSX.Element[] = []
  144. if (
  145. diagnosticsByFile === undefined || diagnosticsByFile[currentFile] === undefined
  146. ) return result
  147. for (const diags of Object.values(diagnosticsByFile)) {
  148. for (const d of diags) {
  149. // Only keep diagnostics explicitly marked as Error (severity === 1)
  150. if (d.severity !== 1) continue
  151. const line = d.range.start.line + 1 // 1-based
  152. const column = d.range.start.character + 1 // 1-based
  153. result.push(
  154. <pre>
  155. <span data-color="red" data-marker="label">Error</span>
  156. <span data-color="dimmed" data-separator>
  157. [{line}:{column}]
  158. </span>
  159. <span>{d.message}</span>
  160. </pre>
  161. )
  162. }
  163. }
  164. return result
  165. }
  166. function stripEnclosingTag(text: string): string {
  167. const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
  168. const match = text.match(wrappedRe)
  169. return match ? match[2] : text
  170. }
  171. function getStatusText(status: [Status, string?]): string {
  172. switch (status[0]) {
  173. case "connected":
  174. return "Connected, waiting for messages..."
  175. case "connecting":
  176. return "Connecting..."
  177. case "disconnected":
  178. return "Disconnected"
  179. case "reconnecting":
  180. return "Reconnecting..."
  181. case "error":
  182. return status[1] || "Error"
  183. default:
  184. return "Unknown"
  185. }
  186. }
  187. function ProviderIcon(props: { provider: string; size?: number }) {
  188. const size = props.size || 16
  189. return (
  190. <Switch fallback={<IconSparkles width={size} height={size} />}>
  191. <Match when={props.provider === "openai"}>
  192. <IconOpenAI width={size} height={size} />
  193. </Match>
  194. <Match when={props.provider === "anthropic"}>
  195. <IconAnthropic width={size} height={size} />
  196. </Match>
  197. <Match when={props.provider === "gemini"}>
  198. <IconGemini width={size} height={size} />
  199. </Match>
  200. </Switch>
  201. )
  202. }
  203. interface ResultsButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
  204. showCopy?: string
  205. hideCopy?: string
  206. results: boolean
  207. }
  208. function ResultsButton(props: ResultsButtonProps) {
  209. const [local, rest] = splitProps(props, ["results", "showCopy", "hideCopy"])
  210. return (
  211. <button
  212. type="button"
  213. data-element-button-text
  214. data-element-button-more
  215. {...rest}
  216. >
  217. <span>
  218. {local.results
  219. ? local.hideCopy || "Hide results"
  220. : local.showCopy || "Show results"}
  221. </span>
  222. <span data-button-icon>
  223. <Show
  224. when={local.results}
  225. fallback={<IconChevronRight width={11} height={11} />}
  226. >
  227. <IconChevronDown width={11} height={11} />
  228. </Show>
  229. </span>
  230. </button>
  231. )
  232. }
  233. interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
  234. text: string
  235. expand?: boolean
  236. invert?: boolean
  237. highlight?: boolean
  238. }
  239. function TextPart(props: TextPartProps) {
  240. const [local, rest] = splitProps(props, ["text", "expand", "invert", "highlight"])
  241. const [expanded, setExpanded] = createSignal(false)
  242. const [overflowed, setOverflowed] = createSignal(false)
  243. let preEl: HTMLPreElement | undefined
  244. function checkOverflow() {
  245. if (preEl && !local.expand) {
  246. setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
  247. }
  248. }
  249. onMount(() => {
  250. checkOverflow()
  251. window.addEventListener("resize", checkOverflow)
  252. })
  253. createEffect(() => {
  254. local.text
  255. setTimeout(checkOverflow, 0)
  256. })
  257. onCleanup(() => {
  258. window.removeEventListener("resize", checkOverflow)
  259. })
  260. return (
  261. <div
  262. class={styles["message-text"]}
  263. data-invert={local.invert}
  264. data-highlight={local.highlight}
  265. data-expanded={expanded() || local.expand === true}
  266. {...rest}
  267. >
  268. <pre ref={(el) => (preEl = el)}>{local.text}</pre>
  269. {((!local.expand && overflowed()) || expanded()) && (
  270. <button
  271. type="button"
  272. data-element-button-text
  273. onClick={() => setExpanded((e) => !e)}
  274. >
  275. {expanded() ? "Show less" : "Show more"}
  276. </button>
  277. )}
  278. </div>
  279. )
  280. }
  281. interface ErrorPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
  282. expand?: boolean
  283. }
  284. function ErrorPart(props: ErrorPartProps) {
  285. const [local, rest] = splitProps(props, ["expand", "children"])
  286. const [expanded, setExpanded] = createSignal(false)
  287. const [overflowed, setOverflowed] = createSignal(false)
  288. let preEl: HTMLElement | undefined
  289. function checkOverflow() {
  290. if (preEl && !local.expand) {
  291. setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
  292. }
  293. }
  294. onMount(() => {
  295. checkOverflow()
  296. window.addEventListener("resize", checkOverflow)
  297. })
  298. createEffect(() => {
  299. local.children
  300. setTimeout(checkOverflow, 0)
  301. })
  302. onCleanup(() => {
  303. window.removeEventListener("resize", checkOverflow)
  304. })
  305. return (
  306. <div
  307. class={styles["message-error"]}
  308. data-expanded={expanded() || local.expand === true}
  309. {...rest}
  310. >
  311. <div data-section="content" ref={(el) => (preEl = el)}>
  312. {local.children}
  313. </div>
  314. {((!local.expand && overflowed()) || expanded()) && (
  315. <button
  316. type="button"
  317. data-element-button-text
  318. onClick={() => setExpanded((e) => !e)}
  319. >
  320. {expanded() ? "Show less" : "Show more"}
  321. </button>
  322. )}
  323. </div>
  324. )
  325. }
  326. interface MarkdownPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
  327. text: string
  328. expand?: boolean
  329. highlight?: boolean
  330. }
  331. function MarkdownPart(props: MarkdownPartProps) {
  332. const [local, rest] = splitProps(props, ["text", "expand", "highlight"])
  333. const [expanded, setExpanded] = createSignal(false)
  334. const [overflowed, setOverflowed] = createSignal(false)
  335. let divEl: HTMLDivElement | undefined
  336. function checkOverflow() {
  337. if (divEl && !local.expand) {
  338. setOverflowed(divEl.scrollHeight > divEl.clientHeight + 1)
  339. }
  340. }
  341. onMount(() => {
  342. checkOverflow()
  343. window.addEventListener("resize", checkOverflow)
  344. })
  345. createEffect(() => {
  346. local.text
  347. setTimeout(checkOverflow, 0)
  348. })
  349. onCleanup(() => {
  350. window.removeEventListener("resize", checkOverflow)
  351. })
  352. return (
  353. <div
  354. class={styles["message-markdown"]}
  355. data-highlight={local.highlight}
  356. data-expanded={expanded() || local.expand === true}
  357. {...rest}
  358. >
  359. <MarkdownView
  360. data-elment-markdown
  361. markdown={local.text}
  362. ref={(el) => (divEl = el)}
  363. />
  364. {((!local.expand && overflowed()) || expanded()) && (
  365. <button
  366. type="button"
  367. data-element-button-text
  368. onClick={() => setExpanded((e) => !e)}
  369. >
  370. {expanded() ? "Show less" : "Show more"}
  371. </button>
  372. )}
  373. </div>
  374. )
  375. }
  376. interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
  377. command: string
  378. result?: string
  379. desc?: string
  380. expand?: boolean
  381. }
  382. function TerminalPart(props: TerminalPartProps) {
  383. const [local, rest] = splitProps(props, ["command", "result", "desc", "expand"])
  384. const [expanded, setExpanded] = createSignal(false)
  385. const [overflowed, setOverflowed] = createSignal(false)
  386. let preEl: HTMLElement | undefined
  387. function checkOverflow() {
  388. if (!preEl) return
  389. const code = preEl.getElementsByTagName("code")[0]
  390. if (code && !local.expand) {
  391. setOverflowed(preEl.clientHeight < code.offsetHeight)
  392. }
  393. }
  394. onMount(() => {
  395. window.addEventListener("resize", checkOverflow)
  396. })
  397. onCleanup(() => {
  398. window.removeEventListener("resize", checkOverflow)
  399. })
  400. return (
  401. <div
  402. class={styles["message-terminal"]}
  403. data-expanded={expanded() || local.expand === true}
  404. {...rest}
  405. >
  406. <div data-section="body">
  407. <div data-section="header">
  408. <span>{local.desc}</span>
  409. </div>
  410. <div data-section="content">
  411. <CodeBlock lang="bash" code={local.command} />
  412. <CodeBlock
  413. lang="console"
  414. onRendered={checkOverflow}
  415. ref={(el) => (preEl = el)}
  416. code={local.result || ""}
  417. />
  418. </div>
  419. </div>
  420. {((!local.expand && overflowed()) || expanded()) && (
  421. <button
  422. type="button"
  423. data-element-button-text
  424. onClick={() => setExpanded((e) => !e)}
  425. >
  426. {expanded() ? "Show less" : "Show more"}
  427. </button>
  428. )}
  429. </div>
  430. )
  431. }
  432. function ToolFooter(props: { time: number }) {
  433. return props.time > MIN_DURATION ? (
  434. <span data-part-footer title={`${props.time}ms`}>
  435. {formatDuration(props.time)}
  436. </span>
  437. ) : (
  438. <div data-part-footer="spacer"></div>
  439. )
  440. }
  441. export default function Share(props: {
  442. id: string
  443. api: string
  444. info: Session.Info
  445. messages: Record<string, Message.Info>
  446. }) {
  447. console.log(props.info)
  448. let hasScrolled = false
  449. const id = props.id
  450. const params = new URLSearchParams(window.location.search)
  451. const debug = params.get("debug") === "true"
  452. const anchorId = createMemo<string | null>(() => {
  453. const raw = window.location.hash.slice(1)
  454. const [id] = raw.split("-")
  455. return id
  456. })
  457. const [store, setStore] = createStore<{
  458. info?: Session.Info
  459. messages: Record<string, Message.Info>
  460. }>({ info: props.info, messages: props.messages })
  461. const messages = createMemo(() =>
  462. Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id))
  463. )
  464. const [connectionStatus, setConnectionStatus] = createSignal<
  465. [Status, string?]
  466. >(["disconnected", "Disconnected"])
  467. onMount(() => {
  468. const apiUrl = props.api
  469. if (!id) {
  470. setConnectionStatus(["error", "id not found"])
  471. return
  472. }
  473. if (!apiUrl) {
  474. console.error("API URL not found in environment variables")
  475. setConnectionStatus(["error", "API URL not found"])
  476. return
  477. }
  478. let reconnectTimer: number | undefined
  479. let socket: WebSocket | null = null
  480. // Function to create and set up WebSocket with auto-reconnect
  481. const setupWebSocket = () => {
  482. // Close any existing connection
  483. if (socket) {
  484. socket.close()
  485. }
  486. setConnectionStatus(["connecting"])
  487. // Always use secure WebSocket protocol (wss)
  488. const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://")
  489. const wsUrl = `${wsBaseUrl}/share_poll?id=${id}`
  490. console.log("Connecting to WebSocket URL:", wsUrl)
  491. // Create WebSocket connection
  492. socket = new WebSocket(wsUrl)
  493. // Handle connection opening
  494. socket.onopen = () => {
  495. setConnectionStatus(["connected"])
  496. console.log("WebSocket connection established")
  497. }
  498. // Handle incoming messages
  499. socket.onmessage = (event) => {
  500. console.log("WebSocket message received")
  501. try {
  502. const d = JSON.parse(event.data)
  503. const [root, type, ...splits] = d.key.split("/")
  504. if (root !== "session") return
  505. if (type === "info") {
  506. setStore("info", reconcile(d.content))
  507. return
  508. }
  509. if (type === "message") {
  510. const [, messageID] = splits
  511. setStore("messages", messageID, reconcile(d.content))
  512. if (!hasScrolled && messageID === anchorId()) {
  513. scrollToAnchor(window.location.hash.slice(1))
  514. hasScrolled = true
  515. }
  516. }
  517. } catch (error) {
  518. console.error("Error parsing WebSocket message:", error)
  519. }
  520. }
  521. // Handle errors
  522. socket.onerror = (error) => {
  523. console.error("WebSocket error:", error)
  524. setConnectionStatus(["error", "Connection failed"])
  525. }
  526. // Handle connection close and reconnection
  527. socket.onclose = (event) => {
  528. console.log(`WebSocket closed: ${event.code} ${event.reason}`)
  529. setConnectionStatus(["reconnecting"])
  530. // Try to reconnect after 2 seconds
  531. clearTimeout(reconnectTimer)
  532. reconnectTimer = window.setTimeout(
  533. setupWebSocket,
  534. 2000,
  535. ) as unknown as number
  536. }
  537. }
  538. // Initial connection
  539. setupWebSocket()
  540. // Clean up on component unmount
  541. onCleanup(() => {
  542. console.log("Cleaning up WebSocket connection")
  543. if (socket) {
  544. socket.close()
  545. }
  546. clearTimeout(reconnectTimer)
  547. })
  548. })
  549. const data = createMemo(() => {
  550. const result = {
  551. rootDir: undefined as string | undefined,
  552. created: undefined as number | undefined,
  553. updated: undefined as number | undefined,
  554. messages: [] as Message.Info[],
  555. models: {} as Record<string, string[]>,
  556. cost: 0,
  557. tokens: {
  558. input: 0,
  559. output: 0,
  560. reasoning: 0,
  561. },
  562. }
  563. result.created = props.info.time.created
  564. result.updated = props.info.time.updated
  565. for (let i = 0; i < messages().length; i++) {
  566. const msg = messages()[i]
  567. // TODO: Cleaup
  568. // const system = result.messages.length === 0 && msg.role === "system"
  569. const assistant = msg.metadata?.assistant
  570. // if (system) {
  571. // for (const part of msg.parts) {
  572. // if (part.type === "text") {
  573. // result.system.push(part.text)
  574. // }
  575. // }
  576. // result.created = msg.metadata?.time.created
  577. // continue
  578. // }
  579. result.messages.push(msg)
  580. if (assistant) {
  581. result.cost += assistant.cost
  582. result.tokens.input += assistant.tokens.input
  583. result.tokens.output += assistant.tokens.output
  584. result.tokens.reasoning += assistant.tokens.reasoning
  585. result.models[`${assistant.providerID} ${assistant.modelID}`] = [
  586. assistant.providerID,
  587. assistant.modelID,
  588. ]
  589. if (assistant.path?.root) {
  590. result.rootDir = assistant.path.root
  591. }
  592. }
  593. }
  594. return result
  595. })
  596. return (
  597. <main class={`${styles.root} not-content`}>
  598. <div class={styles.header}>
  599. <div data-section="title">
  600. <h1>{store.info?.title}</h1>
  601. </div>
  602. <div data-section="row">
  603. <ul data-section="stats">
  604. <li>
  605. <span data-element-label>Cost</span>
  606. {data().cost !== undefined ? (
  607. <span>${data().cost.toFixed(2)}</span>
  608. ) : (
  609. <span data-placeholder>&mdash;</span>
  610. )}
  611. </li>
  612. <li>
  613. <span data-element-label>Input Tokens</span>
  614. {data().tokens.input ? (
  615. <span>{data().tokens.input}</span>
  616. ) : (
  617. <span data-placeholder>&mdash;</span>
  618. )}
  619. </li>
  620. <li>
  621. <span data-element-label>Output Tokens</span>
  622. {data().tokens.output ? (
  623. <span>{data().tokens.output}</span>
  624. ) : (
  625. <span data-placeholder>&mdash;</span>
  626. )}
  627. </li>
  628. <li>
  629. <span data-element-label>Reasoning Tokens</span>
  630. {data().tokens.reasoning ? (
  631. <span>{data().tokens.reasoning}</span>
  632. ) : (
  633. <span data-placeholder>&mdash;</span>
  634. )}
  635. </li>
  636. </ul>
  637. <Show when={data().rootDir}>
  638. <ul data-section="stats" data-section-root>
  639. <li>
  640. <div data-stat-icon>
  641. <IconFolder width={16} height={16} />
  642. </div>
  643. <span>{data().rootDir}</span>
  644. </li>
  645. </ul>
  646. </Show>
  647. <ul data-section="stats" data-section-models>
  648. {Object.values(data().models).length > 0 ? (
  649. <For each={Object.values(data().models)}>
  650. {([provider, model]) => (
  651. <li>
  652. <div data-stat-icon title={provider}>
  653. <ProviderIcon provider={provider} />
  654. </div>
  655. <span data-stat-model>{model}</span>
  656. </li>
  657. )}
  658. </For>
  659. ) : (
  660. <li>
  661. <span data-element-label>Models</span>
  662. <span data-placeholder>&mdash;</span>
  663. </li>
  664. )}
  665. </ul>
  666. <div data-section="time">
  667. {data().created ? (
  668. <span
  669. title={DateTime.fromMillis(
  670. data().created || 0,
  671. ).toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
  672. >
  673. {DateTime.fromMillis(data().created || 0).toLocaleString(
  674. DateTime.DATETIME_MED
  675. )}
  676. </span>
  677. ) : (
  678. <span data-element-label data-placeholder>
  679. Started at &mdash;
  680. </span>
  681. )}
  682. </div>
  683. </div>
  684. </div>
  685. <div>
  686. <Show
  687. when={data().messages.length > 0}
  688. fallback={<p>Waiting for messages...</p>}
  689. >
  690. <div class={styles.parts}>
  691. <For each={data().messages}>
  692. {(msg, msgIndex) => (
  693. <For each={msg.parts}>
  694. {(part, partIndex) => {
  695. if ((
  696. part.type === "step-start" &&
  697. (partIndex() > 0 || !msg.metadata?.assistant)
  698. ) || (
  699. msg.role === "assistant" &&
  700. part.type === "tool-invocation" &&
  701. part.toolInvocation.toolName === "opencode_todoread"
  702. ))
  703. return null
  704. const anchor = createMemo(() => `${msg.id}-${partIndex()}`)
  705. const [showResults, setShowResults] = createSignal(false)
  706. const isLastPart = createMemo(
  707. () =>
  708. data().messages.length === msgIndex() + 1 &&
  709. msg.parts.length === partIndex() + 1,
  710. )
  711. const toolData = createMemo(() => {
  712. if (
  713. msg.role !== "assistant" || part.type !== "tool-invocation"
  714. ) return {}
  715. const metadata = msg.metadata?.tool[part.toolInvocation.toolCallId]
  716. const args = part.toolInvocation.args
  717. const result = part.toolInvocation.state === "result" && part.toolInvocation.result
  718. const duration = DateTime.fromMillis(
  719. metadata?.time.end || 0)
  720. .diff(DateTime.fromMillis(metadata?.time.start || 0))
  721. .toMillis()
  722. return { metadata, args, result, duration }
  723. })
  724. return (
  725. <Switch>
  726. {/* User text */}
  727. <Match
  728. when={
  729. msg.role === "user" && part.type === "text" && part
  730. }
  731. >
  732. {(part) => (
  733. <div
  734. id={anchor()}
  735. data-section="part"
  736. data-part-type="user-text"
  737. >
  738. <div data-section="decoration">
  739. <a href={`#${anchor()}`} title="Message">
  740. <IconUserCircle width={18} height={18} />
  741. </a>
  742. <div></div>
  743. </div>
  744. <div data-section="content">
  745. <TextPart
  746. invert
  747. text={part().text}
  748. expand={isLastPart()}
  749. />
  750. </div>
  751. </div>
  752. )}
  753. </Match>
  754. {/* AI text */}
  755. <Match
  756. when={
  757. msg.role === "assistant" &&
  758. part.type === "text" &&
  759. part
  760. }
  761. >
  762. {(part) => (
  763. <div
  764. id={anchor()}
  765. data-section="part"
  766. data-part-type="ai-text"
  767. >
  768. <div data-section="decoration">
  769. <a href={`#${anchor()}`} title="AI response">
  770. <IconSparkles width={18} height={18} />
  771. </a>
  772. <div></div>
  773. </div>
  774. <div data-section="content">
  775. <MarkdownPart
  776. highlight
  777. expand={isLastPart()}
  778. text={stripEnclosingTag(part().text)}
  779. />
  780. </div>
  781. </div>
  782. )}
  783. </Match>
  784. {/* AI model */}
  785. <Match
  786. when={
  787. msg.role === "assistant" &&
  788. part.type === "step-start" &&
  789. msg.metadata?.assistant
  790. }
  791. >
  792. {(assistant) => {
  793. const system = () => assistant().system || []
  794. return (
  795. <div
  796. id={anchor()}
  797. data-section="part"
  798. data-part-type="ai-model"
  799. >
  800. <div data-section="decoration">
  801. <a href={`#${anchor()}`} title="Model">
  802. <ProviderIcon
  803. size={18}
  804. provider={assistant().providerID}
  805. />
  806. </a>
  807. <div></div>
  808. </div>
  809. <div data-section="content">
  810. <div data-part-tool-body>
  811. <div data-part-title>
  812. <span data-element-label>
  813. {assistant().providerID}
  814. </span>
  815. </div>
  816. <span data-part-model>
  817. {assistant().modelID}
  818. </span>
  819. <div data-part-tool-result>
  820. <ResultsButton
  821. showCopy="Show system prompt"
  822. hideCopy="Hide system prompt"
  823. results={showResults()}
  824. onClick={() =>
  825. setShowResults((e) => !e)
  826. }
  827. />
  828. <Show when={showResults()}>
  829. <TextPart
  830. expand
  831. data-size="sm"
  832. data-color="dimmed"
  833. text={system().join("\n\n").trim()}
  834. />
  835. </Show>
  836. </div>
  837. </div>
  838. </div>
  839. </div>
  840. )
  841. }}
  842. </Match>
  843. {/* System text */}
  844. <Match
  845. when={
  846. msg.role === "system" &&
  847. part.type === "text" &&
  848. part
  849. }
  850. >
  851. {(part) => (
  852. <div
  853. id={anchor()}
  854. data-section="part"
  855. data-part-type="system-text"
  856. >
  857. <div data-section="decoration">
  858. <a href={`#${anchor()}`} title="System message">
  859. <IconCpuChip width={18} height={18} />
  860. </a>
  861. <div></div>
  862. </div>
  863. <div data-section="content">
  864. <div data-part-tool-body>
  865. <div data-part-title>
  866. <span data-element-label>System</span>
  867. </div>
  868. <TextPart
  869. data-size="sm"
  870. text={part().text}
  871. data-color="dimmed"
  872. />
  873. </div>
  874. </div>
  875. </div>
  876. )}
  877. </Match>
  878. {/* Grep tool */}
  879. <Match
  880. when={
  881. msg.role === "assistant" &&
  882. part.type === "tool-invocation" &&
  883. part.toolInvocation.toolName === "opencode_grep" &&
  884. part
  885. }
  886. >
  887. {(_part) => {
  888. const matches = () => toolData()?.metadata?.matches
  889. const splitArgs = () => {
  890. const { pattern, ...rest } = toolData()?.args
  891. return { pattern, rest }
  892. }
  893. return (
  894. <div
  895. id={anchor()}
  896. data-section="part"
  897. data-part-type="tool-grep"
  898. >
  899. <div data-section="decoration">
  900. <a href={`#${anchor()}`} title="Grep files">
  901. <IconDocumentMagnifyingGlass
  902. width={18}
  903. height={18}
  904. />
  905. </a>
  906. <div></div>
  907. </div>
  908. <div data-section="content">
  909. <div data-part-tool-body>
  910. <div data-part-title>
  911. <span data-element-label>Grep</span>
  912. <b>&ldquo;{splitArgs().pattern}&rdquo;</b>
  913. </div>
  914. <Show when={
  915. Object.keys(splitArgs().rest).length > 0
  916. }>
  917. <div data-part-tool-args>
  918. <For each={
  919. flattenToolArgs(splitArgs().rest)
  920. }>
  921. {([name, value]) => (
  922. <>
  923. <div></div>
  924. <div>{name}</div>
  925. <div>{value}</div>
  926. </>
  927. )}
  928. </For>
  929. </div>
  930. </Show>
  931. <Switch>
  932. <Match when={matches() > 0}>
  933. <div data-part-tool-result>
  934. <ResultsButton
  935. showCopy={
  936. matches() === 1
  937. ? "1 match"
  938. : `${matches()} matches`
  939. }
  940. hideCopy="Hide matches"
  941. results={showResults()}
  942. onClick={() =>
  943. setShowResults((e) => !e)
  944. }
  945. />
  946. <Show when={showResults()}>
  947. <TextPart
  948. expand
  949. data-size="sm"
  950. data-color="dimmed"
  951. text={toolData()?.result}
  952. />
  953. </Show>
  954. </div>
  955. </Match>
  956. <Match when={toolData()?.result}>
  957. <div data-part-tool-result>
  958. <TextPart
  959. expand
  960. data-size="sm"
  961. data-color="dimmed"
  962. text={toolData()?.result}
  963. />
  964. </div>
  965. </Match>
  966. </Switch>
  967. </div>
  968. <ToolFooter time={toolData()?.duration || 0} />
  969. </div>
  970. </div>
  971. )
  972. }}
  973. </Match>
  974. {/* Glob tool */}
  975. <Match
  976. when={
  977. msg.role === "assistant" &&
  978. part.type === "tool-invocation" &&
  979. part.toolInvocation.toolName === "opencode_glob" &&
  980. part
  981. }
  982. >
  983. {(_part) => {
  984. const count = () => toolData()?.metadata?.count
  985. const pattern = () => toolData()?.args.pattern
  986. return (
  987. <div
  988. id={anchor()}
  989. data-section="part"
  990. data-part-type="tool-glob"
  991. >
  992. <div data-section="decoration">
  993. <a href={`#${anchor()}`} title="Glob files">
  994. <IconMagnifyingGlass
  995. width={18}
  996. height={18}
  997. />
  998. </a>
  999. <div></div>
  1000. </div>
  1001. <div data-section="content">
  1002. <div data-part-tool-body>
  1003. <div data-part-title>
  1004. <span data-element-label>Glob</span>
  1005. <b>&ldquo;{pattern()}&rdquo;</b>
  1006. </div>
  1007. <Switch>
  1008. <Match when={count() > 0}>
  1009. <div data-part-tool-result>
  1010. <ResultsButton
  1011. showCopy={
  1012. count() === 1
  1013. ? "1 result"
  1014. : `${count()} results`
  1015. }
  1016. results={showResults()}
  1017. onClick={() =>
  1018. setShowResults((e) => !e)
  1019. }
  1020. />
  1021. <Show when={showResults()}>
  1022. <TextPart
  1023. expand
  1024. text={toolData()?.result}
  1025. data-size="sm"
  1026. data-color="dimmed"
  1027. />
  1028. </Show>
  1029. </div>
  1030. </Match>
  1031. <Match when={toolData()?.result}>
  1032. <div data-part-tool-result>
  1033. <TextPart
  1034. expand
  1035. text={toolData()?.result}
  1036. data-size="sm"
  1037. data-color="dimmed"
  1038. />
  1039. </div>
  1040. </Match>
  1041. </Switch>
  1042. </div>
  1043. <ToolFooter time={toolData()?.duration || 0} />
  1044. </div>
  1045. </div>
  1046. )
  1047. }}
  1048. </Match>
  1049. {/* LS tool */}
  1050. <Match
  1051. when={
  1052. msg.role === "assistant" &&
  1053. part.type === "tool-invocation" &&
  1054. part.toolInvocation.toolName === "opencode_list" &&
  1055. part
  1056. }
  1057. >
  1058. {(_part) => {
  1059. const path = createMemo(
  1060. () => toolData()?.args.path !== data().rootDir
  1061. ? stripWorkingDirectory(
  1062. toolData()?.args.path,
  1063. data().rootDir
  1064. )
  1065. : toolData()?.args.path
  1066. )
  1067. return (
  1068. <div
  1069. id={anchor()}
  1070. data-section="part"
  1071. data-part-type="tool-list"
  1072. >
  1073. <div data-section="decoration">
  1074. <a href={`#${anchor()}`} title="List files">
  1075. <IconRectangleStack
  1076. width={18}
  1077. height={18}
  1078. />
  1079. </a>
  1080. <div></div>
  1081. </div>
  1082. <div data-section="content">
  1083. <div data-part-tool-body>
  1084. <div data-part-title>
  1085. <span data-element-label>LS</span>
  1086. <b>{path()}</b>
  1087. </div>
  1088. <Switch>
  1089. <Match when={toolData()?.result}>
  1090. <div data-part-tool-result>
  1091. <ResultsButton
  1092. results={showResults()}
  1093. onClick={() =>
  1094. setShowResults((e) => !e)
  1095. }
  1096. />
  1097. <Show when={showResults()}>
  1098. <TextPart
  1099. expand
  1100. data-size="sm"
  1101. data-color="dimmed"
  1102. text={toolData()?.result}
  1103. />
  1104. </Show>
  1105. </div>
  1106. </Match>
  1107. </Switch>
  1108. </div>
  1109. <ToolFooter time={toolData()?.duration || 0} />
  1110. </div>
  1111. </div>
  1112. )
  1113. }}
  1114. </Match>
  1115. {/* Read tool */}
  1116. <Match
  1117. when={
  1118. msg.role === "assistant" &&
  1119. part.type === "tool-invocation" &&
  1120. part.toolInvocation.toolName === "opencode_read" &&
  1121. part
  1122. }
  1123. >
  1124. {(_part) => {
  1125. const filePath = createMemo(
  1126. () => stripWorkingDirectory(
  1127. toolData()?.args.filePath,
  1128. data().rootDir
  1129. )
  1130. )
  1131. const hasError = () => toolData()?.metadata?.error
  1132. const preview = () => toolData()?.metadata?.preview
  1133. return (
  1134. <div
  1135. id={anchor()}
  1136. data-section="part"
  1137. data-part-type="tool-read"
  1138. >
  1139. <div data-section="decoration">
  1140. <a href={`#${anchor()}`} title="Read file">
  1141. <IconDocument width={18} height={18} />
  1142. </a>
  1143. <div></div>
  1144. </div>
  1145. <div data-section="content">
  1146. <div data-part-tool-body>
  1147. <div data-part-title>
  1148. <span data-element-label>Read</span>
  1149. <b>{filePath()}</b>
  1150. </div>
  1151. <Switch>
  1152. <Match when={hasError()}>
  1153. <div data-part-tool-result>
  1154. <ErrorPart>
  1155. {formatErrorString(toolData()?.result)}
  1156. </ErrorPart>
  1157. </div>
  1158. </Match>
  1159. <Match when={preview()}>
  1160. <div data-part-tool-result>
  1161. <ResultsButton
  1162. showCopy="Show preview"
  1163. hideCopy="Hide preview"
  1164. results={showResults()}
  1165. onClick={() =>
  1166. setShowResults((e) => !e)
  1167. }
  1168. />
  1169. <Show when={showResults()}>
  1170. <div data-part-tool-code>
  1171. <CodeBlock
  1172. lang={getFileType(filePath())}
  1173. code={preview()}
  1174. />
  1175. </div>
  1176. </Show>
  1177. </div>
  1178. </Match>
  1179. <Match when={toolData()?.result}>
  1180. <div data-part-tool-result>
  1181. <ResultsButton
  1182. results={showResults()}
  1183. onClick={() =>
  1184. setShowResults((e) => !e)
  1185. }
  1186. />
  1187. <Show when={showResults()}>
  1188. <TextPart
  1189. expand
  1190. text={toolData()?.result}
  1191. data-size="sm"
  1192. data-color="dimmed"
  1193. />
  1194. </Show>
  1195. </div>
  1196. </Match>
  1197. </Switch>
  1198. </div>
  1199. <ToolFooter time={toolData()?.duration || 0} />
  1200. </div>
  1201. </div>
  1202. )
  1203. }}
  1204. </Match>
  1205. {/* Write tool */}
  1206. <Match
  1207. when={
  1208. msg.role === "assistant" &&
  1209. part.type === "tool-invocation" &&
  1210. part.toolInvocation.toolName === "opencode_write" &&
  1211. part
  1212. }
  1213. >
  1214. {(_part) => {
  1215. const filePath = createMemo(
  1216. () => stripWorkingDirectory(
  1217. toolData()?.args.filePath,
  1218. data().rootDir
  1219. )
  1220. )
  1221. const hasError = () => toolData()?.metadata?.error
  1222. const content = () => toolData()?.args?.content
  1223. const diagnostics = createMemo(() =>
  1224. getDiagnostics(
  1225. toolData()?.metadata?.diagnostics,
  1226. toolData()?.args.filePath
  1227. )
  1228. )
  1229. return (
  1230. <div
  1231. id={anchor()}
  1232. data-section="part"
  1233. data-part-type="tool-write"
  1234. >
  1235. <div data-section="decoration">
  1236. <a href={`#${anchor()}`} title="Write file">
  1237. <IconDocumentPlus width={18} height={18} />
  1238. </a>
  1239. <div></div>
  1240. </div>
  1241. <div data-section="content">
  1242. <div data-part-tool-body>
  1243. <div data-part-title>
  1244. <span data-element-label>Write</span>
  1245. <b>{filePath()}</b>
  1246. </div>
  1247. <Show when={diagnostics().length > 0}>
  1248. <ErrorPart>{diagnostics()}</ErrorPart>
  1249. </Show>
  1250. <Switch>
  1251. <Match when={hasError()}>
  1252. <div data-part-tool-result>
  1253. <ErrorPart>
  1254. {formatErrorString(toolData()?.result)}
  1255. </ErrorPart>
  1256. </div>
  1257. </Match>
  1258. <Match when={content()}>
  1259. <div data-part-tool-result>
  1260. <ResultsButton
  1261. showCopy="Show contents"
  1262. hideCopy="Hide contents"
  1263. results={showResults()}
  1264. onClick={() =>
  1265. setShowResults((e) => !e)
  1266. }
  1267. />
  1268. <Show when={showResults()}>
  1269. <div data-part-tool-code>
  1270. <CodeBlock
  1271. lang={getFileType(filePath())}
  1272. code={content()}
  1273. />
  1274. </div>
  1275. </Show>
  1276. </div>
  1277. </Match>
  1278. </Switch>
  1279. </div>
  1280. <ToolFooter time={toolData()?.duration || 0} />
  1281. </div>
  1282. </div>
  1283. )
  1284. }}
  1285. </Match>
  1286. {/* Edit tool */}
  1287. <Match
  1288. when={
  1289. msg.role === "assistant" &&
  1290. part.type === "tool-invocation" &&
  1291. part.toolInvocation.toolName === "opencode_edit" &&
  1292. part
  1293. }
  1294. >
  1295. {(_part) => {
  1296. const diff = () => toolData()?.metadata?.diff
  1297. const message = () => toolData()?.metadata?.message
  1298. const hasError = () => toolData()?.metadata?.error
  1299. const filePath = createMemo(
  1300. () => stripWorkingDirectory(
  1301. toolData()?.args.filePath,
  1302. data().rootDir
  1303. )
  1304. )
  1305. const diagnostics = createMemo(() =>
  1306. getDiagnostics(
  1307. toolData()?.metadata?.diagnostics,
  1308. toolData()?.args.filePath
  1309. )
  1310. )
  1311. return (
  1312. <div
  1313. id={anchor()}
  1314. data-section="part"
  1315. data-part-type="tool-edit"
  1316. >
  1317. <div data-section="decoration">
  1318. <a href={`#${anchor()}`} title="Edit file">
  1319. <IconPencilSquare width={18} height={18} />
  1320. </a>
  1321. <div></div>
  1322. </div>
  1323. <div data-section="content">
  1324. <div data-part-tool-body>
  1325. <div data-part-title>
  1326. <span data-element-label>Edit</span>
  1327. <b>{filePath()}</b>
  1328. </div>
  1329. <Switch>
  1330. <Match when={hasError()}>
  1331. <div data-part-tool-result>
  1332. <ErrorPart>
  1333. {formatErrorString(message())}
  1334. </ErrorPart>
  1335. </div>
  1336. </Match>
  1337. <Match when={diff()}>
  1338. <div data-part-tool-edit>
  1339. <DiffView
  1340. class={styles["diff-code-block"]}
  1341. diff={diff()}
  1342. lang={getFileType(filePath())}
  1343. />
  1344. </div>
  1345. </Match>
  1346. </Switch>
  1347. <Show when={diagnostics().length > 0}>
  1348. <ErrorPart>{diagnostics()}</ErrorPart>
  1349. </Show>
  1350. </div>
  1351. <ToolFooter time={toolData()?.duration || 0} />
  1352. </div>
  1353. </div>
  1354. )
  1355. }}
  1356. </Match>
  1357. {/* Bash tool */}
  1358. <Match
  1359. when={
  1360. msg.role === "assistant" &&
  1361. part.type === "tool-invocation" &&
  1362. part.toolInvocation.toolName === "opencode_bash" &&
  1363. part
  1364. }
  1365. >
  1366. {(_part) => {
  1367. const command = () => toolData()?.args.command
  1368. const desc = () => toolData()?.args.description
  1369. return (
  1370. <div
  1371. id={anchor()}
  1372. data-section="part"
  1373. data-part-type="tool-bash"
  1374. >
  1375. <div data-section="decoration">
  1376. <a href={`#${anchor()}`} title="Bash command">
  1377. <IconCommandLine width={18} height={18} />
  1378. </a>
  1379. <div></div>
  1380. </div>
  1381. <div data-section="content">
  1382. <div data-part-tool-body>
  1383. <TerminalPart
  1384. desc={desc()}
  1385. data-size="sm"
  1386. command={command()}
  1387. result={toolData()?.result}
  1388. />
  1389. </div>
  1390. <ToolFooter time={toolData()?.duration || 0} />
  1391. </div>
  1392. </div>
  1393. )
  1394. }}
  1395. </Match>
  1396. {/* Todo write */}
  1397. <Match
  1398. when={
  1399. msg.role === "assistant" &&
  1400. part.type === "tool-invocation" &&
  1401. part.toolInvocation.toolName ===
  1402. "opencode_todowrite" &&
  1403. part
  1404. }
  1405. >
  1406. {(_part) => {
  1407. const todos = createMemo(
  1408. () => sortTodosByStatus(toolData()?.args.todos)
  1409. )
  1410. const starting = () => todos().every(
  1411. (t) => t.status === "pending"
  1412. )
  1413. const finished = () => todos().every(
  1414. (t) => t.status === "completed"
  1415. )
  1416. return (
  1417. <div
  1418. id={anchor()}
  1419. data-section="part"
  1420. data-part-type="tool-todo"
  1421. >
  1422. <div data-section="decoration">
  1423. <a href={`#${anchor()}`} title="Plan">
  1424. <IconQueueList width={18} height={18} />
  1425. </a>
  1426. <div></div>
  1427. </div>
  1428. <div data-section="content">
  1429. <div data-part-tool-body>
  1430. <div data-part-title>
  1431. <span data-element-label>
  1432. <Switch fallback="Updating plan">
  1433. <Match when={starting()}>
  1434. Creating plan
  1435. </Match>
  1436. <Match when={finished()}>
  1437. Completing plan
  1438. </Match>
  1439. </Switch>
  1440. </span>
  1441. </div>
  1442. <Show when={todos().length > 0}>
  1443. <ul class={styles.todos}>
  1444. <For each={todos()}>
  1445. {(todo) => (
  1446. <li data-status={todo.status}>
  1447. <span></span>
  1448. {todo.content}
  1449. </li>
  1450. )}
  1451. </For>
  1452. </ul>
  1453. </Show>
  1454. </div>
  1455. <ToolFooter time={toolData()?.duration || 0} />
  1456. </div>
  1457. </div>
  1458. )
  1459. }}
  1460. </Match>
  1461. {/* Fetch tool */}
  1462. <Match
  1463. when={
  1464. msg.role === "assistant" &&
  1465. part.type === "tool-invocation" &&
  1466. part.toolInvocation.toolName ===
  1467. "opencode_webfetch" &&
  1468. part
  1469. }
  1470. >
  1471. {(_part) => {
  1472. const url = () => toolData()?.args.url
  1473. const format = () => toolData()?.args.format
  1474. const hasError = () => toolData()?.metadata?.error
  1475. return (
  1476. <div
  1477. id={anchor()}
  1478. data-section="part"
  1479. data-part-type="tool-fetch"
  1480. >
  1481. <div data-section="decoration">
  1482. <a href={`#${anchor()}`} title="Web fetch">
  1483. <IconGlobeAlt width={18} height={18} />
  1484. </a>
  1485. <div></div>
  1486. </div>
  1487. <div data-section="content">
  1488. <div data-part-tool-body>
  1489. <div data-part-title>
  1490. <span data-element-label>Fetch</span>
  1491. <b>{url()}</b>
  1492. </div>
  1493. <Switch>
  1494. <Match when={hasError()}>
  1495. <div data-part-tool-result>
  1496. <ErrorPart>
  1497. {formatErrorString(toolData()?.result)}
  1498. </ErrorPart>
  1499. </div>
  1500. </Match>
  1501. <Match when={toolData()?.result}>
  1502. <div data-part-tool-result>
  1503. <ResultsButton
  1504. results={showResults()}
  1505. onClick={() =>
  1506. setShowResults((e) => !e)
  1507. }
  1508. />
  1509. <Show when={showResults()}>
  1510. <div data-part-tool-code>
  1511. <CodeBlock
  1512. lang={format() || "text"}
  1513. code={toolData()?.result}
  1514. />
  1515. </div>
  1516. </Show>
  1517. </div>
  1518. </Match>
  1519. </Switch>
  1520. </div>
  1521. <ToolFooter time={toolData()?.duration || 0} />
  1522. </div>
  1523. </div>
  1524. )
  1525. }}
  1526. </Match>
  1527. {/* Tool call */}
  1528. <Match
  1529. when={
  1530. msg.role === "assistant" &&
  1531. part.type === "tool-invocation" &&
  1532. part
  1533. }
  1534. >
  1535. {(part) => {
  1536. return (
  1537. <div
  1538. id={anchor()}
  1539. data-section="part"
  1540. data-part-type="tool-fallback"
  1541. >
  1542. <div data-section="decoration">
  1543. <a href={`#${anchor()}`} title="Tool call">
  1544. <IconWrenchScrewdriver
  1545. width={18}
  1546. height={18}
  1547. />
  1548. </a>
  1549. <div></div>
  1550. </div>
  1551. <div data-section="content">
  1552. <div data-part-tool-body>
  1553. <div data-part-title>
  1554. {part().toolInvocation.toolName}
  1555. </div>
  1556. <div data-part-tool-args>
  1557. <For
  1558. each={flattenToolArgs(
  1559. part().toolInvocation.args,
  1560. )}
  1561. >
  1562. {(arg) => (
  1563. <>
  1564. <div></div>
  1565. <div>{arg[0]}</div>
  1566. <div>{arg[1]}</div>
  1567. </>
  1568. )}
  1569. </For>
  1570. </div>
  1571. <Switch>
  1572. <Match when={toolData()?.result}>
  1573. <div data-part-tool-result>
  1574. <ResultsButton
  1575. results={showResults()}
  1576. onClick={() =>
  1577. setShowResults((e) => !e)
  1578. }
  1579. />
  1580. <Show when={showResults()}>
  1581. <TextPart
  1582. expand
  1583. data-size="sm"
  1584. data-color="dimmed"
  1585. text={toolData()?.result}
  1586. />
  1587. </Show>
  1588. </div>
  1589. </Match>
  1590. <Match
  1591. when={
  1592. part().toolInvocation.state === "call"
  1593. }
  1594. >
  1595. <TextPart
  1596. data-size="sm"
  1597. data-color="dimmed"
  1598. text="Calling..."
  1599. />
  1600. </Match>
  1601. </Switch>
  1602. </div>
  1603. <ToolFooter time={toolData()?.duration || 0} />
  1604. </div>
  1605. </div>
  1606. )
  1607. }}
  1608. </Match>
  1609. {/* Fallback */}
  1610. <Match when={true}>
  1611. <div
  1612. id={anchor()}
  1613. data-section="part"
  1614. data-part-type="fallback"
  1615. >
  1616. <div data-section="decoration">
  1617. <a href={`#${anchor()}`}>
  1618. <Switch
  1619. fallback={
  1620. <IconWrenchScrewdriver
  1621. width={16}
  1622. height={16}
  1623. />
  1624. }
  1625. >
  1626. <Match
  1627. when={
  1628. msg.role === "assistant" &&
  1629. part.type !== "tool-invocation"
  1630. }
  1631. >
  1632. <IconSparkles width={18} height={18} />
  1633. </Match>
  1634. <Match when={msg.role === "system"}>
  1635. <IconCpuChip width={18} height={18} />
  1636. </Match>
  1637. <Match when={msg.role === "user"}>
  1638. <IconUserCircle width={18} height={18} />
  1639. </Match>
  1640. </Switch>
  1641. </a>
  1642. <div></div>
  1643. </div>
  1644. <div data-section="content">
  1645. <div data-part-tool-body>
  1646. <div data-part-title>
  1647. <span data-element-label>
  1648. {part.type}
  1649. </span>
  1650. </div>
  1651. <TextPart
  1652. text={JSON.stringify(part, null, 2)}
  1653. />
  1654. </div>
  1655. </div>
  1656. </div>
  1657. </Match>
  1658. </Switch>
  1659. )
  1660. }}
  1661. </For>
  1662. )}
  1663. </For>
  1664. <div data-section="part" data-part-type="connection-status">
  1665. <div data-section="decoration">
  1666. <span data-status={connectionStatus()[0]}></span>
  1667. <div></div>
  1668. </div>
  1669. <div data-section="content">
  1670. <span>
  1671. {getStatusText(connectionStatus())}
  1672. </span>
  1673. </div>
  1674. </div>
  1675. </div>
  1676. </Show>
  1677. </div>
  1678. <Show when={debug}>
  1679. <div style={{ margin: "2rem 0" }}>
  1680. <div
  1681. style={{
  1682. border: "1px solid #ccc",
  1683. padding: "1rem",
  1684. "overflow-y": "auto",
  1685. }}
  1686. >
  1687. <Show
  1688. when={data().messages.length > 0}
  1689. fallback={<p>Waiting for messages...</p>}
  1690. >
  1691. <ul style={{ "list-style-type": "none", padding: 0 }}>
  1692. <For each={data().messages}>
  1693. {(msg) => (
  1694. <li
  1695. style={{
  1696. padding: "0.75rem",
  1697. margin: "0.75rem 0",
  1698. "box-shadow": "0 1px 3px rgba(0,0,0,0.1)",
  1699. }}
  1700. >
  1701. <div>
  1702. <strong>Key:</strong> {msg.id}
  1703. </div>
  1704. <pre>{JSON.stringify(msg, null, 2)}</pre>
  1705. </li>
  1706. )}
  1707. </For>
  1708. </ul>
  1709. </Show>
  1710. </div>
  1711. </div>
  1712. </Show>
  1713. </main>
  1714. )
  1715. }