| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634 |
- import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList, createEffect } from "solid-js"
- import { DateTime } from "luxon"
- import { createStore, reconcile, unwrap } from "solid-js/store"
- import { IconArrowDown } from "./icons"
- import { IconOpencode } from "./icons/custom"
- import styles from "./share.module.css"
- import type { MessageV2 } from "opencode/session/message-v2"
- import type { Message } from "opencode/session/message"
- import type { Session } from "opencode/session/index"
- import { Part, ProviderIcon } from "./share/part"
- type MessageWithParts = MessageV2.Info & { parts: MessageV2.Part[] }
- type Status = "disconnected" | "connecting" | "connected" | "error" | "reconnecting"
- function scrollToAnchor(id: string) {
- const el = document.getElementById(id)
- if (!el) return
- el.scrollIntoView({ behavior: "smooth" })
- }
- 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"
- }
- }
- export default function Share(props: { id: string; api: string; info: Session.Info }) {
- let lastScrollY = 0
- let hasScrolledToAnchor = false
- let scrollTimeout: number | undefined
- let scrollSentinel: HTMLElement | undefined
- let scrollObserver: IntersectionObserver | undefined
- 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, MessageWithParts>
- }>({
- info: {
- id: props.id,
- title: props.info.title,
- version: props.info.version,
- time: {
- created: props.info.time.created,
- updated: props.info.time.updated,
- },
- },
- messages: {},
- })
- const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id)))
- const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected", "Disconnected"])
- createEffect(() => {
- console.log(unwrap(store))
- })
- onMount(() => {
- const apiUrl = props.api
- if (!props.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=${props.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
- if ("metadata" in d.content) {
- d.content = fromV1(d.content)
- }
- d.content.parts = d.content.parts ?? store.messages[messageID]?.parts ?? []
- setStore("messages", messageID, reconcile(d.content))
- }
- if (type === "part") {
- setStore("messages", d.content.messageID, "parts", (arr) => {
- const index = arr.findIndex((x) => x.id === d.content.id)
- if (index === -1) arr.push(d.content)
- if (index > -1) arr[index] = d.content
- return [...arr]
- })
- }
- } 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 MessageWithParts[],
- models: {} as Record<string, string[]>,
- cost: 0,
- tokens: {
- input: 0,
- output: 0,
- reasoning: 0,
- },
- }
- if (!store.info) return result
- result.created = store.info.time.created
- const msgs = messages()
- for (let i = 0; i < msgs.length; i++) {
- const msg = msgs[i]
- result.messages.push(msg)
- if (msg.role === "assistant") {
- result.cost += msg.cost
- result.tokens.input += msg.tokens.input
- result.tokens.output += msg.tokens.output
- result.tokens.reasoning += msg.tokens.reasoning
- result.models[`${msg.providerID} ${msg.modelID}`] = [msg.providerID, msg.modelID]
- if (msg.path.root) {
- result.rootDir = msg.path.root
- }
- if (msg.time.completed) {
- result.completed = msg.time.completed
- }
- }
- }
- return result
- })
- return (
- <Show when={store.info}>
- <main classList={{ [styles.root]: true, "not-content": true }}>
- <div data-component="header">
- <h1 data-component="header-title">{store.info?.title}</h1>
- <div data-component="header-details">
- <ul data-component="header-stats">
- <li title="opencode version" data-slot="item">
- <div data-slot="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 data-slot="item">
- <div data-slot="icon" title={provider}>
- <ProviderIcon model={model} />
- </div>
- <span data-slot="model">{model}</span>
- </li>
- )}
- </For>
- ) : (
- <li>
- <span data-element-label>Models</span>
- <span data-placeholder>—</span>
- </li>
- )}
- </ul>
- <div
- data-component="header-time"
- title={DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
- >
- {DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_MED)}
- </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) => {
- const filteredParts = createMemo(() =>
- msg.parts.filter((x, index) => {
- if (x.type === "step-start" && index > 0) return false
- if (x.type === "snapshot") return false
- if (x.type === "patch") return false
- if (x.type === "step-finish") return false
- if (x.type === "text" && x.synthetic === true) return false
- if (x.type === "tool" && x.tool === "todoread") return false
- if (x.type === "text" && !x.text) return false
- if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
- return false
- return true
- }),
- )
- return (
- <Suspense>
- <For each={filteredParts()}>
- {(part, partIndex) => {
- const last = createMemo(
- () =>
- data().messages.length === msgIndex() + 1 && filteredParts().length === partIndex() + 1,
- )
- onMount(() => {
- const hash = window.location.hash.slice(1)
- // Wait till all parts are loaded
- if (
- hash !== "" &&
- !hasScrolledToAnchor &&
- filteredParts().length === partIndex() + 1 &&
- data().messages.length === msgIndex() + 1
- ) {
- hasScrolledToAnchor = true
- scrollToAnchor(hash)
- }
- })
- return <Part last={last()} part={part} index={partIndex()} message={msg} />
- }}
- </For>
- </Suspense>
- )
- }}
- </For>
- </SuspenseList>
- <div data-section="part" data-part-type="summary">
- <div data-section="decoration">
- <span data-status={connectionStatus()[0]}></span>
- </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>
- </Show>
- )
- }
- export function fromV1(v1: Message.Info): MessageWithParts {
- if (v1.role === "assistant") {
- return {
- id: v1.id,
- sessionID: v1.metadata.sessionID,
- role: "assistant",
- time: {
- created: v1.metadata.time.created,
- completed: v1.metadata.time.completed,
- },
- cost: v1.metadata.assistant!.cost,
- path: v1.metadata.assistant!.path,
- summary: v1.metadata.assistant!.summary,
- tokens: v1.metadata.assistant!.tokens ?? {
- input: 0,
- output: 0,
- cache: {
- read: 0,
- write: 0,
- },
- reasoning: 0,
- },
- modelID: v1.metadata.assistant!.modelID,
- providerID: v1.metadata.assistant!.providerID,
- mode: "build",
- system: v1.metadata.assistant!.system,
- error: v1.metadata.error,
- parts: v1.parts.flatMap((part, index): MessageV2.Part[] => {
- const base = {
- id: index.toString(),
- messageID: v1.id,
- sessionID: v1.metadata.sessionID,
- }
- if (part.type === "text") {
- return [
- {
- ...base,
- type: "text",
- text: part.text,
- },
- ]
- }
- if (part.type === "step-start") {
- return [
- {
- ...base,
- type: "step-start",
- },
- ]
- }
- if (part.type === "tool-invocation") {
- return [
- {
- ...base,
- type: "tool",
- callID: part.toolInvocation.toolCallId,
- tool: part.toolInvocation.toolName,
- state: (() => {
- if (part.toolInvocation.state === "partial-call") {
- return {
- status: "pending",
- }
- }
- const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId]
- if (part.toolInvocation.state === "call") {
- return {
- status: "running",
- input: part.toolInvocation.args,
- time: {
- start: time.start,
- },
- }
- }
- if (part.toolInvocation.state === "result") {
- return {
- status: "completed",
- input: part.toolInvocation.args,
- output: part.toolInvocation.result,
- title,
- time,
- metadata,
- }
- }
- throw new Error("unknown tool invocation state")
- })(),
- },
- ]
- }
- return []
- }),
- }
- }
- if (v1.role === "user") {
- return {
- id: v1.id,
- sessionID: v1.metadata.sessionID,
- role: "user",
- time: {
- created: v1.metadata.time.created,
- },
- parts: v1.parts.flatMap((part, index): MessageV2.Part[] => {
- const base = {
- id: index.toString(),
- messageID: v1.id,
- sessionID: v1.metadata.sessionID,
- }
- if (part.type === "text") {
- return [
- {
- ...base,
- type: "text",
- text: part.text,
- },
- ]
- }
- if (part.type === "file") {
- return [
- {
- ...base,
- type: "file",
- mime: part.mediaType,
- filename: part.filename,
- url: part.url,
- },
- ]
- }
- return []
- }),
- }
- }
- throw new Error("unknown message type")
- }
|