|
|
@@ -1,7 +1,6 @@
|
|
|
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 { mapValues } from "remeda"
|
|
|
import { IconArrowDown } from "./icons"
|
|
|
import { IconOpencode } from "./icons/custom"
|
|
|
import styles from "./share.module.css"
|
|
|
@@ -42,7 +41,6 @@ export default function Share(props: {
|
|
|
id: string
|
|
|
api: string
|
|
|
info: Session.Info
|
|
|
- messages: Record<string, MessageWithParts>
|
|
|
}) {
|
|
|
let lastScrollY = 0
|
|
|
let hasScrolledToAnchor = false
|
|
|
@@ -50,7 +48,6 @@ export default function Share(props: {
|
|
|
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"
|
|
|
|
|
|
@@ -61,17 +58,27 @@ export default function Share(props: {
|
|
|
const [store, setStore] = createStore<{
|
|
|
info?: Session.Info
|
|
|
messages: Record<string, MessageWithParts>
|
|
|
- }>({ info: props.info, messages: {} })
|
|
|
+ }>({
|
|
|
+ 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))
|
|
|
- // })
|
|
|
+ createEffect(() => {
|
|
|
+ console.log(unwrap(store))
|
|
|
+ })
|
|
|
|
|
|
onMount(() => {
|
|
|
const apiUrl = props.api
|
|
|
|
|
|
- if (!id) {
|
|
|
+ if (!props.id) {
|
|
|
setConnectionStatus(["error", "id not found"])
|
|
|
return
|
|
|
}
|
|
|
@@ -96,7 +103,7 @@ export default function Share(props: {
|
|
|
|
|
|
// Always use secure WebSocket protocol (wss)
|
|
|
const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://")
|
|
|
- const wsUrl = `${wsBaseUrl}/share_poll?id=${id}`
|
|
|
+ const wsUrl = `${wsBaseUrl}/share_poll?id=${props.id}`
|
|
|
console.log("Connecting to WebSocket URL:", wsUrl)
|
|
|
|
|
|
// Create WebSocket connection
|
|
|
@@ -261,7 +268,9 @@ export default function Share(props: {
|
|
|
},
|
|
|
}
|
|
|
|
|
|
- result.created = props.info.time.created
|
|
|
+ if (!store.info) return result
|
|
|
+
|
|
|
+ result.created = store.info.time.created
|
|
|
|
|
|
const msgs = messages()
|
|
|
for (let i = 0; i < msgs.length; i++) {
|
|
|
@@ -290,197 +299,199 @@ export default function Share(props: {
|
|
|
})
|
|
|
|
|
|
return (
|
|
|
- <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>
|
|
|
+ <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>
|
|
|
- )}
|
|
|
- </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)}
|
|
|
+ {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>
|
|
|
-
|
|
|
- <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>
|
|
|
+ <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 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>
|
|
|
+ </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>
|
|
|
)}
|
|
|
- </li>
|
|
|
+ </For>
|
|
|
</ul>
|
|
|
- </div>
|
|
|
+ </Show>
|
|
|
</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={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"
|
|
|
>
|
|
|
- <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>
|
|
|
+ <IconArrowDown width={20} height={20} />
|
|
|
+ </button>
|
|
|
+ </Show>
|
|
|
+ </main>
|
|
|
+ </Show>
|
|
|
)
|
|
|
}
|
|
|
|