| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082 |
- import { type JSX } from "solid-js"
- import {
- For,
- Show,
- Match,
- Switch,
- onMount,
- Suspense,
- onCleanup,
- splitProps,
- createMemo,
- createEffect,
- createSignal,
- SuspenseList,
- } from "solid-js"
- import map from "lang-map"
- import { DateTime } from "luxon"
- import { createStore, reconcile } from "solid-js/store"
- import type { Diagnostic } from "vscode-languageserver-types"
- import {
- IconOpenAI,
- IconGemini,
- IconOpencode,
- IconAnthropic,
- } from "./icons/custom"
- import {
- IconHashtag,
- IconSparkles,
- IconGlobeAlt,
- IconDocument,
- IconQueueList,
- IconUserCircle,
- IconCheckCircle,
- IconChevronDown,
- IconCommandLine,
- IconChevronRight,
- IconDocumentPlus,
- IconPencilSquare,
- IconRectangleStack,
- IconMagnifyingGlass,
- IconWrenchScrewdriver,
- IconDocumentMagnifyingGlass,
- IconArrowDown,
- } 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 (filePath === undefined || 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 getShikiLang(filename: string) {
- const ext = filename.split(".").pop()?.toLowerCase() ?? ""
- // map.languages(ext) returns an array of matching Linguist language names (e.g. ['TypeScript'])
- const langs = map.languages(ext)
- const type = langs?.[0]?.toLowerCase()
- // Overrride any specific language mappings
- const overrides: Record<string, string> = {
- conf: "shellscript",
- }
- return type ? (overrides[type] ?? type) : "plaintext"
- }
- 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
- local.expand
- 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
- local.expand
- 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
- local.expand
- 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-element-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
- error?: string
- result?: string
- desc?: string
- expand?: boolean
- }
- function TerminalPart(props: TerminalPartProps) {
- const [local, rest] = splitProps(props, [
- "command",
- "error",
- "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)
- }
- }
- createEffect(() => {
- local.command
- local.result
- local.error
- local.expand
- setTimeout(checkOverflow, 0)
- })
- onMount(() => {
- checkOverflow()
- 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} />
- <Switch>
- <Match when={local.error}>
- <CodeBlock
- data-section="error"
- lang="text"
- ref={(el) => (preEl = el)}
- code={local.error || ""}
- />
- </Match>
- <Match when={local.result}>
- <CodeBlock
- lang="console"
- ref={(el) => (preEl = el)}
- code={local.result || ""}
- />
- </Match>
- </Switch>
- </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>
- )
- }
- interface AnchorProps extends JSX.HTMLAttributes<HTMLDivElement> {
- id: string
- }
- function AnchorIcon(props: AnchorProps) {
- const [local, rest] = splitProps(props, ["id", "children"])
- const [copied, setCopied] = createSignal(false)
- return (
- <div
- {...rest}
- data-element-anchor
- title="Link to this message"
- data-status={copied() ? "copied" : ""}
- >
- <a
- href={`#${local.id}`}
- onClick={(e) => {
- e.preventDefault()
- const anchor = e.currentTarget
- const hash = anchor.getAttribute("href") || ""
- const { origin, pathname, search } = window.location
- navigator.clipboard
- .writeText(`${origin}${pathname}${search}${hash}`)
- .catch((err) => console.error("Copy failed", err))
- setCopied(true)
- setTimeout(() => setCopied(false), 3000)
- }}
- >
- {local.children}
- <IconHashtag width={18} height={18} />
- <IconCheckCircle width={18} height={18} />
- </a>
- <span data-element-tooltip>Copied!</span>
- </div>
- )
- }
- export default function Share(props: {
- id: string
- api: string
- info: Session.Info
- messages: Record<string, Message.Info>
- }) {
- let lastScrollY = 0
- let scrollTimeout: number | undefined
- let scrollSentinel: HTMLElement | undefined
- let scrollObserver: IntersectionObserver | undefined
- const id = props.id
- const params = new URLSearchParams(window.location.search)
- const debug = params.get("debug") === "true"
- const [showScrollButton, setShowScrollButton] = createSignal(false)
- const [isButtonHovered, setIsButtonHovered] = createSignal(false)
- const [isNearBottom, setIsNearBottom] = createSignal(false)
- 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))
- }
- } 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)
- })
- })
- function checkScrollNeed() {
- const currentScrollY = window.scrollY
- const isScrollingDown = currentScrollY > lastScrollY
- const scrolled = currentScrollY > 200 // Show after scrolling 200px
- // Only show when scrolling down, scrolled enough, and not near bottom
- const shouldShow = isScrollingDown && scrolled && !isNearBottom()
- // Update last scroll position
- lastScrollY = currentScrollY
- if (shouldShow) {
- setShowScrollButton(true)
- // Clear existing timeout
- if (scrollTimeout) {
- clearTimeout(scrollTimeout)
- }
- // Hide button after 3 seconds of no scrolling (unless hovered)
- scrollTimeout = window.setTimeout(() => {
- if (!isButtonHovered()) {
- setShowScrollButton(false)
- }
- }, 1500)
- } else if (!isButtonHovered()) {
- // Only hide if not hovered (to prevent disappearing while user is about to click)
- setShowScrollButton(false)
- if (scrollTimeout) {
- clearTimeout(scrollTimeout)
- }
- }
- }
- onMount(() => {
- lastScrollY = window.scrollY // Initialize scroll position
- // Create sentinel element
- const sentinel = document.createElement("div")
- sentinel.style.height = "1px"
- sentinel.style.position = "absolute"
- sentinel.style.bottom = "100px"
- sentinel.style.width = "100%"
- sentinel.style.pointerEvents = "none"
- document.body.appendChild(sentinel)
- // Create intersection observer
- const observer = new IntersectionObserver((entries) => {
- setIsNearBottom(entries[0].isIntersecting)
- })
- observer.observe(sentinel)
- // Store references for cleanup
- scrollSentinel = sentinel
- scrollObserver = observer
- checkScrollNeed()
- window.addEventListener("scroll", checkScrollNeed)
- window.addEventListener("resize", checkScrollNeed)
- })
- onCleanup(() => {
- window.removeEventListener("scroll", checkScrollNeed)
- window.removeEventListener("resize", checkScrollNeed)
- // Clean up observer and sentinel
- if (scrollObserver) {
- scrollObserver.disconnect()
- }
- if (scrollSentinel) {
- document.body.removeChild(scrollSentinel)
- }
- if (scrollTimeout) {
- clearTimeout(scrollTimeout)
- }
- })
- const data = createMemo(() => {
- const result = {
- rootDir: undefined as string | undefined,
- created: undefined as number | undefined,
- completed: 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
- for (let i = 0; i < messages().length; i++) {
- const msg = messages()[i]
- const assistant = msg.metadata?.assistant
- 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
- }
- if (msg.metadata?.time.completed) {
- result.completed = msg.metadata?.time.completed
- }
- }
- }
- 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" data-section-models>
- <li title="opencode version">
- <div data-stat-icon title="opencode">
- <IconOpencode width={16} height={16} />
- </div>
- <Show when={store.info?.version} fallback="v0.0.1">
- <span>v{store.info?.version}</span>
- </Show>
- </li>
- {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}>
- <SuspenseList revealOrder="forwards">
- <For each={data().messages}>
- {(msg, msgIndex) => (
- <Suspense>
- <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 === "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 }
- })
- onMount(() => {
- const hash = window.location.hash.slice(1)
- // Wait till all parts are loaded
- if (
- hash !== ""
- && msg.parts.length === partIndex() + 1
- && data().messages.length === msgIndex() + 1
- ) {
- scrollToAnchor(hash)
- }
- })
- 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">
- <AnchorIcon id={anchor()}>
- <IconUserCircle width={18} height={18} />
- </AnchorIcon>
- <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">
- <AnchorIcon id={anchor()}>
- <IconSparkles width={18} height={18} />
- </AnchorIcon>
- <div></div>
- </div>
- <div data-section="content">
- <MarkdownPart
- highlight
- expand={isLastPart()}
- text={stripEnclosingTag(part().text)}
- />
- <Show
- when={isLastPart() && data().completed}
- >
- <span
- data-part-footer
- title={DateTime.fromMillis(
- data().completed || 0,
- ).toLocaleString(
- DateTime.DATETIME_FULL_WITH_SECONDS,
- )}
- >
- {DateTime.fromMillis(
- data().completed || 0,
- ).toLocaleString(DateTime.DATETIME_MED)}
- </span>
- </Show>
- </div>
- </div>
- )}
- </Match>
- {/* AI model */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "step-start" &&
- msg.metadata?.assistant
- }
- >
- {(assistant) => {
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="ai-model"
- >
- <div data-section="decoration">
- <AnchorIcon id={anchor()}>
- <ProviderIcon
- size={18}
- provider={assistant().providerID}
- />
- </AnchorIcon>
- <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>
- </div>
- </div>
- )
- }}
- </Match>
- {/* Grep tool */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "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">
- <AnchorIcon id={anchor()}>
- <IconDocumentMagnifyingGlass
- width={18}
- height={18}
- />
- </AnchorIcon>
- <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 === "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">
- <AnchorIcon id={anchor()}>
- <IconMagnifyingGlass
- width={18}
- height={18}
- />
- </AnchorIcon>
- <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 === "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">
- <AnchorIcon id={anchor()}>
- <IconRectangleStack
- width={18}
- height={18}
- />
- </AnchorIcon>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>LS</span>
- <b title={toolData()?.args?.path}>
- {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 === "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">
- <AnchorIcon id={anchor()}>
- <IconDocument width={18} height={18} />
- </AnchorIcon>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>Read</span>
- <b title={toolData()?.args?.filePath}>
- {filePath()}
- </b>
- </div>
- <Switch>
- <Match when={hasError()}>
- <div data-part-tool-result>
- <ErrorPart>
- {formatErrorString(
- toolData()?.result,
- )}
- </ErrorPart>
- </div>
- </Match>
- {/* Always try to show CodeBlock if preview is available (even if empty string) */}
- <Match
- when={typeof preview() === "string"}
- >
- <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={getShikiLang(
- filePath(),
- )}
- code={preview()}
- />
- </div>
- </Show>
- </div>
- </Match>
- {/* Fallback to TextPart if preview is not a string (e.g. undefined) AND result exists */}
- <Match
- when={
- typeof preview() !== "string" &&
- 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 === "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">
- <AnchorIcon id={anchor()}>
- <IconDocumentPlus
- width={18}
- height={18}
- />
- </AnchorIcon>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>Write</span>
- <b title={toolData()?.args?.filePath}>
- {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={getShikiLang(
- filePath(),
- )}
- code={
- toolData()?.args?.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 === "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">
- <AnchorIcon id={anchor()}>
- <IconPencilSquare
- width={18}
- height={18}
- />
- </AnchorIcon>
- <div></div>
- </div>
- <div data-section="content">
- <div data-part-tool-body>
- <div data-part-title>
- <span data-element-label>Edit</span>
- <b title={toolData()?.args?.filePath}>
- {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={getShikiLang(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 === "bash" &&
- part
- }
- >
- {(_part) => {
- const command = () =>
- toolData()?.metadata?.title
- const desc = () =>
- toolData()?.metadata?.description
- const result = () =>
- toolData()?.metadata?.stdout
- const error = () => toolData()?.metadata?.stderr
- return (
- <div
- id={anchor()}
- data-section="part"
- data-part-type="tool-bash"
- >
- <div data-section="decoration">
- <AnchorIcon id={anchor()}>
- <IconCommandLine
- width={18}
- height={18}
- />
- </AnchorIcon>
- <div></div>
- </div>
- <div data-section="content">
- {command() && (
- <div data-part-tool-body>
- <TerminalPart
- desc={desc()}
- data-size="sm"
- command={command()!}
- result={result()}
- error={error()}
- />
- </div>
- )}
- <ToolFooter
- time={toolData()?.duration || 0}
- />
- </div>
- </div>
- )
- }}
- </Match>
- {/* Todo write */}
- <Match
- when={
- msg.role === "assistant" &&
- part.type === "tool-invocation" &&
- part.toolInvocation.toolName === "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">
- <AnchorIcon id={anchor()}>
- <IconQueueList width={18} height={18} />
- </AnchorIcon>
- <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 === "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">
- <AnchorIcon id={anchor()}>
- <IconGlobeAlt width={18} height={18} />
- </AnchorIcon>
- <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">
- <AnchorIcon id={anchor()}>
- <IconWrenchScrewdriver
- width={18}
- height={18}
- />
- </AnchorIcon>
- <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">
- <AnchorIcon id={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 === "user"}>
- <IconUserCircle
- width={18}
- height={18}
- />
- </Match>
- </Switch>
- </AnchorIcon>
- <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>
- </Suspense>
- )}
- </For>
- </SuspenseList>
- <div data-section="part" data-part-type="summary">
- <div data-section="decoration">
- <span data-status={connectionStatus()[0]}></span>
- <div></div>
- </div>
- <div data-section="content">
- <p data-section="copy">{getStatusText(connectionStatus())}</p>
- <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>
- </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>
- <Show when={showScrollButton()}>
- <button
- type="button"
- class={styles["scroll-button"]}
- onClick={() =>
- document.body.scrollIntoView({ behavior: "smooth", block: "end" })
- }
- onMouseEnter={() => {
- setIsButtonHovered(true)
- if (scrollTimeout) {
- clearTimeout(scrollTimeout)
- }
- }}
- onMouseLeave={() => {
- setIsButtonHovered(false)
- if (showScrollButton()) {
- scrollTimeout = window.setTimeout(() => {
- if (!isButtonHovered()) {
- setShowScrollButton(false)
- }
- }, 3000)
- }
- }}
- title="Scroll to bottom"
- aria-label="Scroll to bottom"
- >
- <IconArrowDown width={20} height={20} />
- </button>
- </Show>
- </main>
- )
- }
|