| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077 |
- 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)
- if (hash !== "" && hash === anchor()) {
- 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>
- )
- }
|