| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819 |
- import { type JSX } from "solid-js"
- import {
- For,
- Show,
- Match,
- Switch,
- onMount,
- onCleanup,
- splitProps,
- createMemo,
- createEffect,
- createSignal,
- } from "solid-js"
- import { DateTime } from "luxon"
- import { createStore, reconcile } from "solid-js/store"
- import type { Diagnostic } from "vscode-languageserver-types"
- import { IconOpenAI, IconGemini, IconAnthropic } from "./icons/custom"
- import {
- IconFolder,
- IconCpuChip,
- IconSparkles,
- IconGlobeAlt,
- IconDocument,
- IconQueueList,
- IconUserCircle,
- IconChevronDown,
- IconCommandLine,
- IconChevronRight,
- IconDocumentPlus,
- IconPencilSquare,
- IconRectangleStack,
- IconMagnifyingGlass,
- IconWrenchScrewdriver,
- IconDocumentMagnifyingGlass,
- } from "./icons"
- import DiffView from "./DiffView"
- import CodeBlock from "./CodeBlock"
- import MarkdownView from "./MarkdownView"
- import styles from "./share.module.css"
- import type { Message } from "opencode/session/message"
- import type { Session } from "opencode/session/index"
- const MIN_DURATION = 2
- type Status =
- | "disconnected"
- | "connecting"
- | "connected"
- | "error"
- | "reconnecting"
- type TodoStatus = "pending" | "in_progress" | "completed"
- interface Todo {
- id: string
- content: string
- status: TodoStatus
- priority: "low" | "medium" | "high"
- }
- function sortTodosByStatus(todos: Todo[]) {
- const statusPriority: Record<TodoStatus, number> = {
- in_progress: 0,
- pending: 1,
- completed: 2,
- }
- return todos
- .slice()
- .sort((a, b) => statusPriority[a.status] - statusPriority[b.status])
- }
- function scrollToAnchor(id: string) {
- const el = document.getElementById(id)
- if (!el) return
- el.scrollIntoView({ behavior: "smooth" })
- }
- function stripWorkingDirectory(filePath: string, workingDir?: string) {
- if (workingDir === undefined) return filePath
- const prefix = workingDir.endsWith('/') ? workingDir : workingDir + '/'
- if (filePath === workingDir) {
- return ''
- }
- if (filePath.startsWith(prefix)) {
- return filePath.slice(prefix.length)
- }
- return filePath
- }
- function getFileType(path: string) {
- return path.split(".").pop()
- }
- function formatDuration(ms: number): string {
- const ONE_SECOND = 1000
- const ONE_MINUTE = 60 * ONE_SECOND
- if (ms >= ONE_MINUTE) {
- const minutes = Math.floor(ms / ONE_MINUTE)
- return minutes === 1 ? `1min` : `${minutes}mins`
- }
- if (ms >= ONE_SECOND) {
- const seconds = Math.floor(ms / ONE_SECOND)
- return `${seconds}s`
- }
- return `${ms}ms`
- }
- // Converts nested objects/arrays into [path, value] pairs.
- // E.g. {a:{b:{c:1}}, d:[{e:2}, 3]} => [["a.b.c",1], ["d[0].e",2], ["d[1]",3]]
- function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> {
- const entries: Array<[string, any]> = []
- for (const [key, value] of Object.entries(obj)) {
- const path = prefix ? `${prefix}.${key}` : key
- if (value !== null && typeof value === "object") {
- if (Array.isArray(value)) {
- value.forEach((item, index) => {
- const arrayPath = `${path}[${index}]`
- if (item !== null && typeof item === "object") {
- entries.push(...flattenToolArgs(item, arrayPath))
- } else {
- entries.push([arrayPath, item])
- }
- })
- } else {
- entries.push(...flattenToolArgs(value, path))
- }
- } else {
- entries.push([path, value])
- }
- }
- return entries
- }
- function formatErrorString(error: string): JSX.Element {
- const errorMarker = "Error: "
- const startsWithError = error.startsWith(errorMarker)
- return startsWithError ? (
- <pre>
- <span data-color="red" data-marker="label" data-separator>
- Error
- </span>
- <span>{error.slice(errorMarker.length)}</span>
- </pre>
- ) : (
- <pre><span data-color="dimmed">{error}</span></pre>
- )
- }
- function getDiagnostics(
- diagnosticsByFile: Record<string, Diagnostic[]>,
- currentFile: string
- ): JSX.Element[] {
- // Return a flat array of error diagnostics, in the format:
- // "Error [65:20] Property 'x' does not exist on type 'Y'"
- const result: JSX.Element[] = []
- if (
- diagnosticsByFile === undefined || diagnosticsByFile[currentFile] === undefined
- ) return result
- for (const diags of Object.values(diagnosticsByFile)) {
- for (const d of diags) {
- // Only keep diagnostics explicitly marked as Error (severity === 1)
- if (d.severity !== 1) continue
- const line = d.range.start.line + 1 // 1-based
- const column = d.range.start.character + 1 // 1-based
- result.push(
- <pre>
- <span data-color="red" data-marker="label">Error</span>
- <span data-color="dimmed" data-separator>
- [{line}:{column}]
- </span>
- <span>{d.message}</span>
- </pre>
- )
- }
- }
- return result
- }
- function stripEnclosingTag(text: string): string {
- const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
- const match = text.match(wrappedRe)
- return match ? match[2] : text
- }
- function getStatusText(status: [Status, string?]): string {
- switch (status[0]) {
- case "connected":
- return "Connected, waiting for messages..."
- case "connecting":
- return "Connecting..."
- case "disconnected":
- return "Disconnected"
- case "reconnecting":
- return "Reconnecting..."
- case "error":
- return status[1] || "Error"
- default:
- return "Unknown"
- }
- }
- function ProviderIcon(props: { provider: string; size?: number }) {
- const size = props.size || 16
- return (
- <Switch fallback={<IconSparkles width={size} height={size} />}>
- <Match when={props.provider === "openai"}>
- <IconOpenAI width={size} height={size} />
- </Match>
- <Match when={props.provider === "anthropic"}>
- <IconAnthropic width={size} height={size} />
- </Match>
- <Match when={props.provider === "gemini"}>
- <IconGemini width={size} height={size} />
- </Match>
- </Switch>
- )
- }
- interface ResultsButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
- showCopy?: string
- hideCopy?: string
- results: boolean
- }
- function ResultsButton(props: ResultsButtonProps) {
- const [local, rest] = splitProps(props, ["results", "showCopy", "hideCopy"])
- return (
- <button
- type="button"
- data-element-button-text
- data-element-button-more
- {...rest}
- >
- <span>
- {local.results
- ? local.hideCopy || "Hide results"
- : local.showCopy || "Show results"}
- </span>
- <span data-button-icon>
- <Show
- when={local.results}
- fallback={<IconChevronRight width={11} height={11} />}
- >
- <IconChevronDown width={11} height={11} />
- </Show>
- </span>
- </button>
- )
- }
- interface TextPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
- text: string
- expand?: boolean
- invert?: boolean
- highlight?: boolean
- }
- function TextPart(props: TextPartProps) {
- const [local, rest] = splitProps(props, ["text", "expand", "invert", "highlight"])
- const [expanded, setExpanded] = createSignal(false)
- const [overflowed, setOverflowed] = createSignal(false)
- let preEl: HTMLPreElement | undefined
- function checkOverflow() {
- if (preEl && !local.expand) {
- setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
- }
- }
- onMount(() => {
- checkOverflow()
- window.addEventListener("resize", checkOverflow)
- })
- createEffect(() => {
- local.text
- setTimeout(checkOverflow, 0)
- })
- onCleanup(() => {
- window.removeEventListener("resize", checkOverflow)
- })
- return (
- <div
- class={styles["message-text"]}
- data-invert={local.invert}
- data-highlight={local.highlight}
- data-expanded={expanded() || local.expand === true}
- {...rest}
- >
- <pre ref={(el) => (preEl = el)}>{local.text}</pre>
- {((!local.expand && overflowed()) || expanded()) && (
- <button
- type="button"
- data-element-button-text
- onClick={() => setExpanded((e) => !e)}
- >
- {expanded() ? "Show less" : "Show more"}
- </button>
- )}
- </div>
- )
- }
- interface ErrorPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
- expand?: boolean
- }
- function ErrorPart(props: ErrorPartProps) {
- const [local, rest] = splitProps(props, ["expand", "children"])
- const [expanded, setExpanded] = createSignal(false)
- const [overflowed, setOverflowed] = createSignal(false)
- let preEl: HTMLElement | undefined
- function checkOverflow() {
- if (preEl && !local.expand) {
- setOverflowed(preEl.scrollHeight > preEl.clientHeight + 1)
- }
- }
- onMount(() => {
- checkOverflow()
- window.addEventListener("resize", checkOverflow)
- })
- createEffect(() => {
- local.children
- setTimeout(checkOverflow, 0)
- })
- onCleanup(() => {
- window.removeEventListener("resize", checkOverflow)
- })
- return (
- <div
- class={styles["message-error"]}
- data-expanded={expanded() || local.expand === true}
- {...rest}
- >
- <div data-section="content" ref={(el) => (preEl = el)}>
- {local.children}
- </div>
- {((!local.expand && overflowed()) || expanded()) && (
- <button
- type="button"
- data-element-button-text
- onClick={() => setExpanded((e) => !e)}
- >
- {expanded() ? "Show less" : "Show more"}
- </button>
- )}
- </div>
- )
- }
- interface MarkdownPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
- text: string
- expand?: boolean
- highlight?: boolean
- }
- function MarkdownPart(props: MarkdownPartProps) {
- const [local, rest] = splitProps(props, ["text", "expand", "highlight"])
- const [expanded, setExpanded] = createSignal(false)
- const [overflowed, setOverflowed] = createSignal(false)
- let divEl: HTMLDivElement | undefined
- function checkOverflow() {
- if (divEl && !local.expand) {
- setOverflowed(divEl.scrollHeight > divEl.clientHeight + 1)
- }
- }
- onMount(() => {
- checkOverflow()
- window.addEventListener("resize", checkOverflow)
- })
- createEffect(() => {
- local.text
- setTimeout(checkOverflow, 0)
- })
- onCleanup(() => {
- window.removeEventListener("resize", checkOverflow)
- })
- return (
- <div
- class={styles["message-markdown"]}
- data-highlight={local.highlight}
- data-expanded={expanded() || local.expand === true}
- {...rest}
- >
- <MarkdownView
- data-elment-markdown
- markdown={local.text}
- ref={(el) => (divEl = el)}
- />
- {((!local.expand && overflowed()) || expanded()) && (
- <button
- type="button"
- data-element-button-text
- onClick={() => setExpanded((e) => !e)}
- >
- {expanded() ? "Show less" : "Show more"}
- </button>
- )}
- </div>
- )
- }
- interface TerminalPartProps extends JSX.HTMLAttributes<HTMLDivElement> {
- command: string
- result?: string
- desc?: string
- expand?: boolean
- }
- function TerminalPart(props: TerminalPartProps) {
- const [local, rest] = splitProps(props, ["command", "result", "desc", "expand"])
- const [expanded, setExpanded] = createSignal(false)
- const [overflowed, setOverflowed] = createSignal(false)
- let preEl: HTMLElement | undefined
- function checkOverflow() {
- if (!preEl) return
- const code = preEl.getElementsByTagName("code")[0]
- if (code && !local.expand) {
- setOverflowed(preEl.clientHeight < code.offsetHeight)
- }
- }
- onMount(() => {
- window.addEventListener("resize", checkOverflow)
- })
- onCleanup(() => {
- window.removeEventListener("resize", checkOverflow)
- })
- return (
- <div
- class={styles["message-terminal"]}
- data-expanded={expanded() || local.expand === true}
- {...rest}
- >
- <div data-section="body">
- <div data-section="header">
- <span>{local.desc}</span>
- </div>
- <div data-section="content">
- <CodeBlock lang="bash" code={local.command} />
- <CodeBlock
- lang="console"
- onRendered={checkOverflow}
- ref={(el) => (preEl = el)}
- code={local.result || ""}
- />
- </div>
- </div>
- {((!local.expand && overflowed()) || expanded()) && (
- <button
- type="button"
- data-element-button-text
- onClick={() => setExpanded((e) => !e)}
- >
- {expanded() ? "Show less" : "Show more"}
- </button>
- )}
- </div>
- )
- }
- function ToolFooter(props: { time: number }) {
- return props.time > MIN_DURATION ? (
- <span data-part-footer title={`${props.time}ms`}>
- {formatDuration(props.time)}
- </span>
- ) : (
- <div data-part-footer="spacer"></div>
- )
- }
- export default function Share(props: {
- id: string
- api: string
- info: Session.Info
- messages: Record<string, Message.Info>
- }) {
- console.log(props.info)
- let hasScrolled = false
- const id = props.id
- const params = new URLSearchParams(window.location.search)
- const debug = params.get("debug") === "true"
- const anchorId = createMemo<string | null>(() => {
- const raw = window.location.hash.slice(1)
- const [id] = raw.split("-")
- return id
- })
- const [store, setStore] = createStore<{
- info?: Session.Info
- messages: Record<string, Message.Info>
- }>({ info: props.info, messages: props.messages })
- const messages = createMemo(() =>
- Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id))
- )
- const [connectionStatus, setConnectionStatus] = createSignal<
- [Status, string?]
- >(["disconnected", "Disconnected"])
- onMount(() => {
- const apiUrl = props.api
- if (!id) {
- setConnectionStatus(["error", "id not found"])
- return
- }
- if (!apiUrl) {
- console.error("API URL not found in environment variables")
- setConnectionStatus(["error", "API URL not found"])
- return
- }
- let reconnectTimer: number | undefined
- let socket: WebSocket | null = null
- // Function to create and set up WebSocket with auto-reconnect
- const setupWebSocket = () => {
- // Close any existing connection
- if (socket) {
- socket.close()
- }
- setConnectionStatus(["connecting"])
- // Always use secure WebSocket protocol (wss)
- const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://")
- const wsUrl = `${wsBaseUrl}/share_poll?id=${id}`
- console.log("Connecting to WebSocket URL:", wsUrl)
- // Create WebSocket connection
- socket = new WebSocket(wsUrl)
- // Handle connection opening
- socket.onopen = () => {
- setConnectionStatus(["connected"])
- console.log("WebSocket connection established")
- }
- // Handle incoming messages
- socket.onmessage = (event) => {
- console.log("WebSocket message received")
- try {
- const d = JSON.parse(event.data)
- const [root, type, ...splits] = d.key.split("/")
- if (root !== "session") return
- if (type === "info") {
- setStore("info", reconcile(d.content))
- return
- }
- if (type === "message") {
- const [, messageID] = splits
- setStore("messages", messageID, reconcile(d.content))
- if (!hasScrolled && messageID === anchorId()) {
- scrollToAnchor(window.location.hash.slice(1))
- hasScrolled = true
- }
- }
- } catch (error) {
- console.error("Error parsing WebSocket message:", error)
- }
- }
- // Handle errors
- socket.onerror = (error) => {
- console.error("WebSocket error:", error)
- setConnectionStatus(["error", "Connection failed"])
- }
- // Handle connection close and reconnection
- socket.onclose = (event) => {
- console.log(`WebSocket closed: ${event.code} ${event.reason}`)
- setConnectionStatus(["reconnecting"])
- // Try to reconnect after 2 seconds
- clearTimeout(reconnectTimer)
- reconnectTimer = window.setTimeout(
- setupWebSocket,
- 2000,
- ) as unknown as number
- }
- }
- // Initial connection
- setupWebSocket()
- // Clean up on component unmount
- onCleanup(() => {
- console.log("Cleaning up WebSocket connection")
- if (socket) {
- socket.close()
- }
- clearTimeout(reconnectTimer)
- })
- })
- const data = createMemo(() => {
- const result = {
- rootDir: undefined as string | undefined,
- created: undefined as number | undefined,
- updated: undefined as number | undefined,
- messages: [] as Message.Info[],
- models: {} as Record<string, string[]>,
- cost: 0,
- tokens: {
- input: 0,
- output: 0,
- reasoning: 0,
- },
- }
- result.created = props.info.time.created
- result.updated = props.info.time.updated
- for (let i = 0; i < messages().length; i++) {
- const msg = messages()[i]
- // TODO: Cleaup
- // const system = result.messages.length === 0 && msg.role === "system"
- const assistant = msg.metadata?.assistant
- // if (system) {
- // for (const part of msg.parts) {
- // if (part.type === "text") {
- // result.system.push(part.text)
- // }
- // }
- // result.created = msg.metadata?.time.created
- // continue
- // }
- result.messages.push(msg)
- if (assistant) {
- result.cost += assistant.cost
- result.tokens.input += assistant.tokens.input
- result.tokens.output += assistant.tokens.output
- result.tokens.reasoning += assistant.tokens.reasoning
- result.models[`${assistant.providerID} ${assistant.modelID}`] = [
- assistant.providerID,
- assistant.modelID,
- ]
- if (assistant.path?.root) {
- result.rootDir = assistant.path.root
- }
- }
- }
- return result
- })
- return (
- <main class={`${styles.root} not-content`}>
- <div class={styles.header}>
- <div data-section="title">
- <h1>{store.info?.title}</h1>
- </div>
- <div data-section="row">
- <ul data-section="stats">
- <li>
- <span data-element-label>Cost</span>
- {data().cost !== undefined ? (
- <span>${data().cost.toFixed(2)}</span>
- ) : (
- <span data-placeholder>—</span>
- )}
- </li>
- <li>
- <span data-element-label>Input Tokens</span>
- {data().tokens.input ? (
- <span>{data().tokens.input}</span>
- ) : (
- <span data-placeholder>—</span>
- )}
- </li>
- <li>
- <span data-element-label>Output Tokens</span>
- {data().tokens.output ? (
- <span>{data().tokens.output}</span>
- ) : (
- <span data-placeholder>—</span>
- )}
- </li>
- <li>
- <span data-element-label>Reasoning Tokens</span>
- {data().tokens.reasoning ? (
- <span>{data().tokens.reasoning}</span>
- ) : (
- <span data-placeholder>—</span>
- )}
- </li>
- </ul>
- <Show when={data().rootDir}>
- <ul data-section="stats" data-section-root>
- <li>
- <div data-stat-icon>
- <IconFolder width={16} height={16} />
- </div>
- <span>{data().rootDir}</span>
- </li>
- </ul>
- </Show>
- <ul data-section="stats" data-section-models>
- {Object.values(data().models).length > 0 ? (
- <For each={Object.values(data().models)}>
- {([provider, model]) => (
- <li>
- <div data-stat-icon title={provider}>
- <ProviderIcon provider={provider} />
- </div>
- <span data-stat-model>{model}</span>
- </li>
- )}
- </For>
- ) : (
- <li>
- <span data-element-label>Models</span>
- <span data-placeholder>—</span>
- </li>
- )}
- </ul>
- <div data-section="time">
- {data().created ? (
- <span
- title={DateTime.fromMillis(
- data().created || 0,
- ).toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
- >
- {DateTime.fromMillis(data().created || 0).toLocaleString(
- DateTime.DATETIME_MED
- )}
- </span>
- ) : (
- <span data-element-label data-placeholder>
- Started at —
- </span>
- )}
- </div>
- </div>
- </div>
- <div>
- <Show
- when={data().messages.length > 0}
- fallback={<p>Waiting for messages...</p>}
- >
- <div class={styles.parts}>
- <For each={data().messages}>
- {(msg, msgIndex) => (
- <For each={msg.parts}>
- {(part, partIndex) => {
- if ((
- part.type === "step-start" &&
- (partIndex() > 0 || !msg.metadata?.assistant)
- ) || (
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "opencode_todoread"
- ))
- return null
- const anchor = createMemo(() => `${msg.id}-${partIndex()}`)
- const [showResults, setShowResults] = createSignal(false)
- const isLastPart = createMemo(
- () =>
- data().messages.length === msgIndex() + 1 &&
- msg.parts.length === partIndex() + 1,
- )
- const toolData = createMemo(() => {
- if (
- msg.role !== "assistant" || part.type !== "tool-invocation"
- ) return {}
- const metadata = msg.metadata?.tool[part.toolInvocation.toolCallId]
- const args = part.toolInvocation.args
- const result = part.toolInvocation.state === "result" && part.toolInvocation.result
- const duration = DateTime.fromMillis(
- metadata?.time.end || 0)
- .diff(DateTime.fromMillis(metadata?.time.start || 0))
- .toMillis()
- return { metadata, args, result, duration }
- })
- return (
- <Switch>
- {/* User text */}
- <Match
- when={
- msg.role === "user" && part.type === "text" && part
- }
- >
- {(part) => (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="user-text"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Message">
- <IconUserCircle width={18} height={18} />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <TextPart
- invert
- text={part().text}
- expand={isLastPart()}
- />
- </div>
- </div>
- )}
- </Match>
- {/* AI text */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "text" &&
- part
- }
- >
- {(part) => (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="ai-text"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="AI response">
- <IconSparkles width={18} height={18} />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <MarkdownPart
- highlight
- expand={isLastPart()}
- text={stripEnclosingTag(part().text)}
- />
- </div>
- </div>
- )}
- </Match>
- {/* AI model */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "step-start" &&
- msg.metadata?.assistant
- }
- >
- {(assistant) => {
- const system = () => assistant().system || []
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="ai-model"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Model">
- <ProviderIcon
- size={18}
- provider={assistant().providerID}
- />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>
- {assistant().providerID}
- </span>
- </div>
- <span data-part-model>
- {assistant().modelID}
- </span>
- <div data-part-tool-result>
- <ResultsButton
- showCopy="Show system prompt"
- hideCopy="Hide system prompt"
- results={showResults()}
- onClick={() =>
- setShowResults((e) => !e)
- }
- />
- <Show when={showResults()}>
- <TextPart
- expand
- data-size="sm"
- data-color="dimmed"
- text={system().join("\n\n").trim()}
- />
- </Show>
- </div>
- </div>
- </div>
- </div>
- )
- }}
- </Match>
- {/* System text */}
- <Match
- when={
- msg.role === "system" &&
- part.type === "text" &&
- part
- }
- >
- {(part) => (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="system-text"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="System message">
- <IconCpuChip width={18} height={18} />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>System</span>
- </div>
- <TextPart
- data-size="sm"
- text={part().text}
- data-color="dimmed"
- />
- </div>
- </div>
- </div>
- )}
- </Match>
- {/* Grep tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "opencode_grep" &&
- part
- }
- >
- {(_part) => {
- const matches = () => toolData()?.metadata?.matches
- const splitArgs = () => {
- const { pattern, ...rest } = toolData()?.args
- return { pattern, rest }
- }
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-grep"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Grep files">
- <IconDocumentMagnifyingGlass
- width={18}
- height={18}
- />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>Grep</span>
- <b>“{splitArgs().pattern}”</b>
- </div>
- <Show when={
- Object.keys(splitArgs().rest).length > 0
- }>
- <div data-part-tool-args>
- <For each={
- flattenToolArgs(splitArgs().rest)
- }>
- {([name, value]) => (
- <>
- <div></div>
- <div>{name}</div>
- <div>{value}</div>
- </>
- )}
- </For>
- </div>
- </Show>
- <Switch>
- <Match when={matches() > 0}>
- <div data-part-tool-result>
- <ResultsButton
- showCopy={
- matches() === 1
- ? "1 match"
- : `${matches()} matches`
- }
- hideCopy="Hide matches"
- results={showResults()}
- onClick={() =>
- setShowResults((e) => !e)
- }
- />
- <Show when={showResults()}>
- <TextPart
- expand
- data-size="sm"
- data-color="dimmed"
- text={toolData()?.result}
- />
- </Show>
- </div>
- </Match>
- <Match when={toolData()?.result}>
- <div data-part-tool-result>
- <TextPart
- expand
- data-size="sm"
- data-color="dimmed"
- text={toolData()?.result}
- />
- </div>
- </Match>
- </Switch>
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Glob tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "opencode_glob" &&
- part
- }
- >
- {(_part) => {
- const count = () => toolData()?.metadata?.count
- const pattern = () => toolData()?.args.pattern
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-glob"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Glob files">
- <IconMagnifyingGlass
- width={18}
- height={18}
- />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>Glob</span>
- <b>“{pattern()}”</b>
- </div>
- <Switch>
- <Match when={count() > 0}>
- <div data-part-tool-result>
- <ResultsButton
- showCopy={
- count() === 1
- ? "1 result"
- : `${count()} results`
- }
- results={showResults()}
- onClick={() =>
- setShowResults((e) => !e)
- }
- />
- <Show when={showResults()}>
- <TextPart
- expand
- text={toolData()?.result}
- data-size="sm"
- data-color="dimmed"
- />
- </Show>
- </div>
- </Match>
- <Match when={toolData()?.result}>
- <div data-part-tool-result>
- <TextPart
- expand
- text={toolData()?.result}
- data-size="sm"
- data-color="dimmed"
- />
- </div>
- </Match>
- </Switch>
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* LS tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "opencode_list" &&
- part
- }
- >
- {(_part) => {
- const path = createMemo(
- () => toolData()?.args.path !== data().rootDir
- ? stripWorkingDirectory(
- toolData()?.args.path,
- data().rootDir
- )
- : toolData()?.args.path
- )
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-list"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="List files">
- <IconRectangleStack
- width={18}
- height={18}
- />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>LS</span>
- <b>{path()}</b>
- </div>
- <Switch>
- <Match when={toolData()?.result}>
- <div data-part-tool-result>
- <ResultsButton
- results={showResults()}
- onClick={() =>
- setShowResults((e) => !e)
- }
- />
- <Show when={showResults()}>
- <TextPart
- expand
- data-size="sm"
- data-color="dimmed"
- text={toolData()?.result}
- />
- </Show>
- </div>
- </Match>
- </Switch>
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Read tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "opencode_read" &&
- part
- }
- >
- {(_part) => {
- const filePath = createMemo(
- () => stripWorkingDirectory(
- toolData()?.args.filePath,
- data().rootDir
- )
- )
- const hasError = () => toolData()?.metadata?.error
- const preview = () => toolData()?.metadata?.preview
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-read"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Read file">
- <IconDocument width={18} height={18} />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>Read</span>
- <b>{filePath()}</b>
- </div>
- <Switch>
- <Match when={hasError()}>
- <div data-part-tool-result>
- <ErrorPart>
- {formatErrorString(toolData()?.result)}
- </ErrorPart>
- </div>
- </Match>
- <Match when={preview()}>
- <div data-part-tool-result>
- <ResultsButton
- showCopy="Show preview"
- hideCopy="Hide preview"
- results={showResults()}
- onClick={() =>
- setShowResults((e) => !e)
- }
- />
- <Show when={showResults()}>
- <div data-part-tool-code>
- <CodeBlock
- lang={getFileType(filePath())}
- code={preview()}
- />
- </div>
- </Show>
- </div>
- </Match>
- <Match when={toolData()?.result}>
- <div data-part-tool-result>
- <ResultsButton
- results={showResults()}
- onClick={() =>
- setShowResults((e) => !e)
- }
- />
- <Show when={showResults()}>
- <TextPart
- expand
- text={toolData()?.result}
- data-size="sm"
- data-color="dimmed"
- />
- </Show>
- </div>
- </Match>
- </Switch>
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Write tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "opencode_write" &&
- part
- }
- >
- {(_part) => {
- const filePath = createMemo(
- () => stripWorkingDirectory(
- toolData()?.args.filePath,
- data().rootDir
- )
- )
- const hasError = () => toolData()?.metadata?.error
- const content = () => toolData()?.args?.content
- const diagnostics = createMemo(() =>
- getDiagnostics(
- toolData()?.metadata?.diagnostics,
- toolData()?.args.filePath
- )
- )
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-write"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Write file">
- <IconDocumentPlus width={18} height={18} />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>Write</span>
- <b>{filePath()}</b>
- </div>
- <Show when={diagnostics().length > 0}>
- <ErrorPart>{diagnostics()}</ErrorPart>
- </Show>
- <Switch>
- <Match when={hasError()}>
- <div data-part-tool-result>
- <ErrorPart>
- {formatErrorString(toolData()?.result)}
- </ErrorPart>
- </div>
- </Match>
- <Match when={content()}>
- <div data-part-tool-result>
- <ResultsButton
- showCopy="Show contents"
- hideCopy="Hide contents"
- results={showResults()}
- onClick={() =>
- setShowResults((e) => !e)
- }
- />
- <Show when={showResults()}>
- <div data-part-tool-code>
- <CodeBlock
- lang={getFileType(filePath())}
- code={content()}
- />
- </div>
- </Show>
- </div>
- </Match>
- </Switch>
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Edit tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "opencode_edit" &&
- part
- }
- >
- {(_part) => {
- const diff = () => toolData()?.metadata?.diff
- const message = () => toolData()?.metadata?.message
- const hasError = () => toolData()?.metadata?.error
- const filePath = createMemo(
- () => stripWorkingDirectory(
- toolData()?.args.filePath,
- data().rootDir
- )
- )
- const diagnostics = createMemo(() =>
- getDiagnostics(
- toolData()?.metadata?.diagnostics,
- toolData()?.args.filePath
- )
- )
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-edit"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Edit file">
- <IconPencilSquare width={18} height={18} />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>Edit</span>
- <b>{filePath()}</b>
- </div>
- <Switch>
- <Match when={hasError()}>
- <div data-part-tool-result>
- <ErrorPart>
- {formatErrorString(message())}
- </ErrorPart>
- </div>
- </Match>
- <Match when={diff()}>
- <div data-part-tool-edit>
- <DiffView
- class={styles["diff-code-block"]}
- diff={diff()}
- lang={getFileType(filePath())}
- />
- </div>
- </Match>
- </Switch>
- <Show when={diagnostics().length > 0}>
- <ErrorPart>{diagnostics()}</ErrorPart>
- </Show>
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Bash tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "opencode_bash" &&
- part
- }
- >
- {(_part) => {
- const command = () => toolData()?.args.command
- const desc = () => toolData()?.args.description
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-bash"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Bash command">
- <IconCommandLine width={18} height={18} />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <TerminalPart
- desc={desc()}
- data-size="sm"
- command={command()}
- result={toolData()?.result}
- />
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Todo write */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName ===
- "opencode_todowrite" &&
- part
- }
- >
- {(_part) => {
- const todos = createMemo(
- () => sortTodosByStatus(toolData()?.args.todos)
- )
- const starting = () => todos().every(
- (t) => t.status === "pending"
- )
- const finished = () => todos().every(
- (t) => t.status === "completed"
- )
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-todo"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Plan">
- <IconQueueList width={18} height={18} />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>
- <Switch fallback="Updating plan">
- <Match when={starting()}>
- Creating plan
- </Match>
- <Match when={finished()}>
- Completing plan
- </Match>
- </Switch>
- </span>
- </div>
- <Show when={todos().length > 0}>
- <ul class={styles.todos}>
- <For each={todos()}>
- {(todo) => (
- <li data-status={todo.status}>
- <span></span>
- {todo.content}
- </li>
- )}
- </For>
- </ul>
- </Show>
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Fetch tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName ===
- "opencode_webfetch" &&
- part
- }
- >
- {(_part) => {
- const url = () => toolData()?.args.url
- const format = () => toolData()?.args.format
- const hasError = () => toolData()?.metadata?.error
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-fetch"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Web fetch">
- <IconGlobeAlt width={18} height={18} />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>Fetch</span>
- <b>{url()}</b>
- </div>
- <Switch>
- <Match when={hasError()}>
- <div data-part-tool-result>
- <ErrorPart>
- {formatErrorString(toolData()?.result)}
- </ErrorPart>
- </div>
- </Match>
- <Match when={toolData()?.result}>
- <div data-part-tool-result>
- <ResultsButton
- results={showResults()}
- onClick={() =>
- setShowResults((e) => !e)
- }
- />
- <Show when={showResults()}>
- <div data-part-tool-code>
- <CodeBlock
- lang={format() || "text"}
- code={toolData()?.result}
- />
- </div>
- </Show>
- </div>
- </Match>
- </Switch>
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Tool call */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part
- }
- >
- {(part) => {
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-fallback"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`} title="Tool call">
- <IconWrenchScrewdriver
- width={18}
- height={18}
- />
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- {part().toolInvocation.toolName}
- </div>
- <div data-part-tool-args>
- <For
- each={flattenToolArgs(
- part().toolInvocation.args,
- )}
- >
- {(arg) => (
- <>
- <div></div>
- <div>{arg[0]}</div>
- <div>{arg[1]}</div>
- </>
- )}
- </For>
- </div>
- <Switch>
- <Match when={toolData()?.result}>
- <div data-part-tool-result>
- <ResultsButton
- results={showResults()}
- onClick={() =>
- setShowResults((e) => !e)
- }
- />
- <Show when={showResults()}>
- <TextPart
- expand
- data-size="sm"
- data-color="dimmed"
- text={toolData()?.result}
- />
- </Show>
- </div>
- </Match>
- <Match
- when={
- part().toolInvocation.state === "call"
- }
- >
- <TextPart
- data-size="sm"
- data-color="dimmed"
- text="Calling..."
- />
- </Match>
- </Switch>
- </div>
- <ToolFooter time={toolData()?.duration || 0} />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Fallback */}
- <Match when={true}>
- <div
- id={anchor()}
- data-section="part"
- data-part-type="fallback"
- >
- <div data-section="decoration">
- <a href={`#${anchor()}`}>
- <Switch
- fallback={
- <IconWrenchScrewdriver
- width={16}
- height={16}
- />
- }
- >
- <Match
- when={
- msg.role === "assistant" &&
- part.type !== "tool-invocation"
- }
- >
- <IconSparkles width={18} height={18} />
- </Match>
- <Match when={msg.role === "system"}>
- <IconCpuChip width={18} height={18} />
- </Match>
- <Match when={msg.role === "user"}>
- <IconUserCircle width={18} height={18} />
- </Match>
- </Switch>
- </a>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>
- {part.type}
- </span>
- </div>
- <TextPart
- text={JSON.stringify(part, null, 2)}
- />
- </div>
- </div>
- </div>
- </Match>
- </Switch>
- )
- }}
- </For>
- )}
- </For>
- <div data-section="part" data-part-type="connection-status">
- <div data-section="decoration">
- <span data-status={connectionStatus()[0]}></span>
- <div></div>
- </div>
- <div data-section="content">
- <span>
- {getStatusText(connectionStatus())}
- </span>
- </div>
- </div>
- </div>
- </Show>
- </div>
- <Show when={debug}>
- <div style={{ margin: "2rem 0" }}>
- <div
- style={{
- border: "1px solid #ccc",
- padding: "1rem",
- "overflow-y": "auto",
- }}
- >
- <Show
- when={data().messages.length > 0}
- fallback={<p>Waiting for messages...</p>}
- >
- <ul style={{ "list-style-type": "none", padding: 0 }}>
- <For each={data().messages}>
- {(msg) => (
- <li
- style={{
- padding: "0.75rem",
- margin: "0.75rem 0",
- "box-shadow": "0 1px 3px rgba(0,0,0,0.1)",
- }}
- >
- <div>
- <strong>Key:</strong> {msg.id}
- </div>
- <pre>{JSON.stringify(msg, null, 2)}</pre>
- </li>
- )}
- </For>
- </ul>
- </Show>
- </div>
- </div>
- </Show>
- </main>
- )
- }
|