Buckets:
| 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.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 | |
| // 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}` | |
| // Create WebSocket connection | |
| socket = new WebSocket(wsUrl) | |
| // Handle connection opening | |
| socket.onopen = () => { | |
| setConnectionStatus(["connected"]) | |
| } | |
| // Handle incoming messages | |
| 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) | |
| } | |
| } | |
| // Handle errors | |
| socket.onerror = (error) => { | |
| console.error("WebSocket error:", error) | |
| setConnectionStatus(["error", props.messages.error_connection_failed]) | |
| } | |
| // Handle connection close and reconnection | |
| socket.onclose = () => { | |
| 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(() => { | |
| 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}> | |
| <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") | |
| } | |
Xet Storage Details
- Size:
- 21.6 kB
- Xet hash:
- 04ecf50c75459448502be7fa7f6680e34559028f75e4519d1a10cf37655b851e
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.