| import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList } from "solid-js" |
| import { DateTime } from "luxon" |
| import { createStore, reconcile } from "solid-js/store" |
| import { IconArrowDown } from "./icons" |
| import { IconOpencode } from "./icons/custom" |
| import { ShareI18nProvider, formatCurrency, formatNumber, normalizeLocale } from "./share/common" |
| 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?], messages: Record<string, string>): string { |
| switch (status[0]) { |
| case "connected": |
| return messages.status_connected_waiting |
| case "connecting": |
| return messages.status_connecting |
| case "disconnected": |
| return messages.status_disconnected |
| case "reconnecting": |
| return messages.status_reconnecting |
| case "error": |
| return status[1] || messages.status_error |
| default: |
| return messages.status_unknown |
| } |
| } |
|
|
| export default function Share(props: { |
| id: string |
| api: string |
| info: Session.Info |
| messages: { locale: string } & Record<string, string> |
| }) { |
| 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, |
| slug: props.info.slug, |
| projectID: props.info.projectID, |
| directory: props.info.directory, |
| 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.time.created - b.time.created || a.id.localeCompare(b.id)), |
| ) |
| const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected"]) |
|
|
| onMount(() => { |
| const apiUrl = props.api |
|
|
| if (!props.id) { |
| setConnectionStatus(["error", props.messages.error_id_not_found]) |
| return |
| } |
|
|
| if (!apiUrl) { |
| console.error("API URL not found in environment variables") |
| setConnectionStatus(["error", props.messages.error_api_url_not_found]) |
| return |
| } |
|
|
| let reconnectTimer: number | undefined |
| let socket: WebSocket | null = null |
|
|
| |
| const setupWebSocket = () => { |
| |
| if (socket) { |
| socket.close() |
| } |
|
|
| setConnectionStatus(["connecting"]) |
|
|
| |
| const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://") |
| const wsUrl = `${wsBaseUrl}/share_poll?id=${props.id}` |
| |
| socket = new WebSocket(wsUrl) |
|
|
| |
| socket.onopen = () => { |
| setConnectionStatus(["connected"]) |
| } |
|
|
| |
| socket.onmessage = (event) => { |
| 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) |
| } |
| } |
|
|
| |
| socket.onerror = (error) => { |
| console.error("WebSocket error:", error) |
| setConnectionStatus(["error", props.messages.error_connection_failed]) |
| } |
|
|
| |
| socket.onclose = () => { |
| setConnectionStatus(["reconnecting"]) |
|
|
| |
| clearTimeout(reconnectTimer) |
| reconnectTimer = window.setTimeout(setupWebSocket, 2000) as unknown as number |
| } |
| } |
|
|
| |
| setupWebSocket() |
|
|
| |
| onCleanup(() => { |
| if (socket) { |
| socket.close() |
| } |
| clearTimeout(reconnectTimer) |
| }) |
| }) |
|
|
| function checkScrollNeed() { |
| const currentScrollY = window.scrollY |
| const isScrollingDown = currentScrollY > lastScrollY |
| const scrolled = currentScrollY > 200 |
|
|
| |
| const shouldShow = isScrollingDown && scrolled && !isNearBottom() |
|
|
| |
| lastScrollY = currentScrollY |
|
|
| if (shouldShow) { |
| setShowScrollButton(true) |
| |
| if (scrollTimeout) { |
| clearTimeout(scrollTimeout) |
| } |
| |
| scrollTimeout = window.setTimeout(() => { |
| if (!isButtonHovered()) { |
| setShowScrollButton(false) |
| } |
| }, 1500) |
| } else if (!isButtonHovered()) { |
| |
| setShowScrollButton(false) |
| if (scrollTimeout) { |
| clearTimeout(scrollTimeout) |
| } |
| } |
| } |
|
|
| onMount(() => { |
| lastScrollY = window.scrollY |
|
|
| |
| 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) |
|
|
| |
| const observer = new IntersectionObserver((entries) => { |
| setIsNearBottom(entries[0].isIntersecting) |
| }) |
| observer.observe(sentinel) |
|
|
| |
| scrollSentinel = sentinel |
| scrollObserver = observer |
|
|
| checkScrollNeed() |
| window.addEventListener("scroll", checkScrollNeed) |
| window.addEventListener("resize", checkScrollNeed) |
| }) |
|
|
| onCleanup(() => { |
| window.removeEventListener("scroll", checkScrollNeed) |
| window.removeEventListener("resize", checkScrollNeed) |
|
|
| |
| 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}> |
| <ShareI18nProvider messages={props.messages}> |
| <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={props.messages.opencode_version} data-slot="item"> |
| <div data-slot="icon" title={props.messages.opencode_name}> |
| <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>{props.messages.models}</span> |
| <span data-placeholder>—</span> |
| </li> |
| )} |
| </ul> |
| <div |
| data-component="header-time" |
| title={DateTime.fromMillis(data().created || 0) |
| .setLocale(normalizeLocale(props.messages.locale)) |
| .toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)} |
| > |
| {DateTime.fromMillis(data().created || 0) |
| .setLocale(normalizeLocale(props.messages.locale)) |
| .toLocaleString(DateTime.DATETIME_MED)} |
| </div> |
| </div> |
| </div> |
| |
| <div> |
| <Show when={data().messages.length > 0} fallback={<p>{props.messages.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 === "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 = () => |
| 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 && last()) { |
| 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(), props.messages)}</p> |
| <ul data-section="stats"> |
| <li> |
| <span data-element-label>{props.messages.cost}</span> |
| {data().cost !== undefined ? ( |
| <span>{formatCurrency(data().cost, props.messages.locale)}</span> |
| ) : ( |
| <span data-placeholder>—</span> |
| )} |
| </li> |
| <li> |
| <span data-element-label>{props.messages.input_tokens}</span> |
| {data().tokens.input ? ( |
| <span>{formatNumber(data().tokens.input, props.messages.locale)}</span> |
| ) : ( |
| <span data-placeholder>—</span> |
| )} |
| </li> |
| <li> |
| <span data-element-label>{props.messages.output_tokens}</span> |
| {data().tokens.output ? ( |
| <span>{formatNumber(data().tokens.output, props.messages.locale)}</span> |
| ) : ( |
| <span data-placeholder>—</span> |
| )} |
| </li> |
| <li> |
| <span data-element-label>{props.messages.reasoning_tokens}</span> |
| {data().tokens.reasoning ? ( |
| <span>{formatNumber(data().tokens.reasoning, props.messages.locale)}</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>{props.messages.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>{props.messages.debug_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={props.messages.scroll_to_bottom} |
| aria-label={props.messages.scroll_to_bottom} |
| > |
| <IconArrowDown width={20} height={20} /> |
| </button> |
| </Show> |
| </main> |
| </ShareI18nProvider> |
| </Show> |
| ) |
| } |
|
|
| export function fromV1(v1: Message.Info): MessageWithParts { |
| if (v1.role === "assistant") { |
| return { |
| id: v1.id, |
| sessionID: v1.metadata.sessionID, |
| role: "assistant", |
| parentID: "", |
| agent: "build", |
| 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", |
| 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", |
| input: {}, |
| raw: "", |
| } |
| } |
|
|
| 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", |
| agent: "user", |
| model: { |
| providerID: "", |
| modelID: "", |
| }, |
| 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") |
| } |
|
|