Share.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList, createEffect } from "solid-js"
  2. import { DateTime } from "luxon"
  3. import { createStore, reconcile, unwrap } from "solid-js/store"
  4. import { IconArrowDown } from "./icons"
  5. import { IconOpencode } from "./icons/custom"
  6. import styles from "./share.module.css"
  7. import type { MessageV2 } from "opencode/session/message-v2"
  8. import type { Message } from "opencode/session/message"
  9. import type { Session } from "opencode/session/index"
  10. import { Part, ProviderIcon } from "./share/part"
  11. type MessageWithParts = MessageV2.Info & { parts: MessageV2.Part[] }
  12. type Status = "disconnected" | "connecting" | "connected" | "error" | "reconnecting"
  13. function scrollToAnchor(id: string) {
  14. const el = document.getElementById(id)
  15. if (!el) return
  16. el.scrollIntoView({ behavior: "smooth" })
  17. }
  18. function getStatusText(status: [Status, string?]): string {
  19. switch (status[0]) {
  20. case "connected":
  21. return "Connected, waiting for messages..."
  22. case "connecting":
  23. return "Connecting..."
  24. case "disconnected":
  25. return "Disconnected"
  26. case "reconnecting":
  27. return "Reconnecting..."
  28. case "error":
  29. return status[1] || "Error"
  30. default:
  31. return "Unknown"
  32. }
  33. }
  34. export default function Share(props: {
  35. id: string
  36. api: string
  37. info: Session.Info
  38. messages: Record<string, MessageWithParts>
  39. }) {
  40. let lastScrollY = 0
  41. let hasScrolledToAnchor = false
  42. let scrollTimeout: number | undefined
  43. let scrollSentinel: HTMLElement | undefined
  44. let scrollObserver: IntersectionObserver | undefined
  45. const id = props.id
  46. const params = new URLSearchParams(window.location.search)
  47. const debug = params.get("debug") === "true"
  48. const [showScrollButton, setShowScrollButton] = createSignal(false)
  49. const [isButtonHovered, setIsButtonHovered] = createSignal(false)
  50. const [isNearBottom, setIsNearBottom] = createSignal(false)
  51. const [store, setStore] = createStore<{
  52. info?: Session.Info
  53. messages: Record<string, MessageWithParts>
  54. }>({ info: props.info, messages: props.messages })
  55. const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id)))
  56. const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected", "Disconnected"])
  57. createEffect(() => {
  58. console.log(unwrap(store))
  59. })
  60. onMount(() => {
  61. const apiUrl = props.api
  62. if (!id) {
  63. setConnectionStatus(["error", "id not found"])
  64. return
  65. }
  66. if (!apiUrl) {
  67. console.error("API URL not found in environment variables")
  68. setConnectionStatus(["error", "API URL not found"])
  69. return
  70. }
  71. let reconnectTimer: number | undefined
  72. let socket: WebSocket | null = null
  73. // Function to create and set up WebSocket with auto-reconnect
  74. const setupWebSocket = () => {
  75. // Close any existing connection
  76. if (socket) {
  77. socket.close()
  78. }
  79. setConnectionStatus(["connecting"])
  80. // Always use secure WebSocket protocol (wss)
  81. const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://")
  82. const wsUrl = `${wsBaseUrl}/share_poll?id=${id}`
  83. console.log("Connecting to WebSocket URL:", wsUrl)
  84. // Create WebSocket connection
  85. socket = new WebSocket(wsUrl)
  86. // Handle connection opening
  87. socket.onopen = () => {
  88. setConnectionStatus(["connected"])
  89. console.log("WebSocket connection established")
  90. }
  91. // Handle incoming messages
  92. socket.onmessage = (event) => {
  93. console.log("WebSocket message received")
  94. try {
  95. const d = JSON.parse(event.data)
  96. const [root, type, ...splits] = d.key.split("/")
  97. if (root !== "session") return
  98. if (type === "info") {
  99. setStore("info", reconcile(d.content))
  100. return
  101. }
  102. if (type === "message") {
  103. const [, messageID] = splits
  104. if ("metadata" in d.content) {
  105. d.content = fromV1(d.content)
  106. }
  107. d.content.parts = d.content.parts ?? store.messages[messageID]?.parts ?? []
  108. setStore("messages", messageID, reconcile(d.content))
  109. }
  110. if (type === "part") {
  111. setStore("messages", d.content.messageID, "parts", arr => {
  112. const index = arr.findIndex((x) => x.id === d.content.id)
  113. if (index === -1)
  114. arr.push(d.content)
  115. if (index > -1)
  116. arr[index] = d.content
  117. return [...arr]
  118. })
  119. }
  120. } catch (error) {
  121. console.error("Error parsing WebSocket message:", error)
  122. }
  123. }
  124. // Handle errors
  125. socket.onerror = (error) => {
  126. console.error("WebSocket error:", error)
  127. setConnectionStatus(["error", "Connection failed"])
  128. }
  129. // Handle connection close and reconnection
  130. socket.onclose = (event) => {
  131. console.log(`WebSocket closed: ${event.code} ${event.reason}`)
  132. setConnectionStatus(["reconnecting"])
  133. // Try to reconnect after 2 seconds
  134. clearTimeout(reconnectTimer)
  135. reconnectTimer = window.setTimeout(setupWebSocket, 2000) as unknown as number
  136. }
  137. }
  138. // Initial connection
  139. setupWebSocket()
  140. // Clean up on component unmount
  141. onCleanup(() => {
  142. console.log("Cleaning up WebSocket connection")
  143. if (socket) {
  144. socket.close()
  145. }
  146. clearTimeout(reconnectTimer)
  147. })
  148. })
  149. function checkScrollNeed() {
  150. const currentScrollY = window.scrollY
  151. const isScrollingDown = currentScrollY > lastScrollY
  152. const scrolled = currentScrollY > 200 // Show after scrolling 200px
  153. // Only show when scrolling down, scrolled enough, and not near bottom
  154. const shouldShow = isScrollingDown && scrolled && !isNearBottom()
  155. // Update last scroll position
  156. lastScrollY = currentScrollY
  157. if (shouldShow) {
  158. setShowScrollButton(true)
  159. // Clear existing timeout
  160. if (scrollTimeout) {
  161. clearTimeout(scrollTimeout)
  162. }
  163. // Hide button after 3 seconds of no scrolling (unless hovered)
  164. scrollTimeout = window.setTimeout(() => {
  165. if (!isButtonHovered()) {
  166. setShowScrollButton(false)
  167. }
  168. }, 1500)
  169. } else if (!isButtonHovered()) {
  170. // Only hide if not hovered (to prevent disappearing while user is about to click)
  171. setShowScrollButton(false)
  172. if (scrollTimeout) {
  173. clearTimeout(scrollTimeout)
  174. }
  175. }
  176. }
  177. onMount(() => {
  178. lastScrollY = window.scrollY // Initialize scroll position
  179. // Create sentinel element
  180. const sentinel = document.createElement("div")
  181. sentinel.style.height = "1px"
  182. sentinel.style.position = "absolute"
  183. sentinel.style.bottom = "100px"
  184. sentinel.style.width = "100%"
  185. sentinel.style.pointerEvents = "none"
  186. document.body.appendChild(sentinel)
  187. // Create intersection observer
  188. const observer = new IntersectionObserver((entries) => {
  189. setIsNearBottom(entries[0].isIntersecting)
  190. })
  191. observer.observe(sentinel)
  192. // Store references for cleanup
  193. scrollSentinel = sentinel
  194. scrollObserver = observer
  195. checkScrollNeed()
  196. window.addEventListener("scroll", checkScrollNeed)
  197. window.addEventListener("resize", checkScrollNeed)
  198. })
  199. onCleanup(() => {
  200. window.removeEventListener("scroll", checkScrollNeed)
  201. window.removeEventListener("resize", checkScrollNeed)
  202. // Clean up observer and sentinel
  203. if (scrollObserver) {
  204. scrollObserver.disconnect()
  205. }
  206. if (scrollSentinel) {
  207. document.body.removeChild(scrollSentinel)
  208. }
  209. if (scrollTimeout) {
  210. clearTimeout(scrollTimeout)
  211. }
  212. })
  213. const data = createMemo(() => {
  214. const result = {
  215. rootDir: undefined as string | undefined,
  216. created: undefined as number | undefined,
  217. completed: undefined as number | undefined,
  218. messages: [] as MessageWithParts[],
  219. models: {} as Record<string, string[]>,
  220. cost: 0,
  221. tokens: {
  222. input: 0,
  223. output: 0,
  224. reasoning: 0,
  225. },
  226. }
  227. result.created = props.info.time.created
  228. const msgs = messages()
  229. for (let i = 0; i < msgs.length; i++) {
  230. const msg = msgs[i]
  231. result.messages.push(msg)
  232. if (msg.role === "assistant") {
  233. result.cost += msg.cost
  234. result.tokens.input += msg.tokens.input
  235. result.tokens.output += msg.tokens.output
  236. result.tokens.reasoning += msg.tokens.reasoning
  237. result.models[`${msg.providerID} ${msg.modelID}`] = [msg.providerID, msg.modelID]
  238. if (msg.path.root) {
  239. result.rootDir = msg.path.root
  240. }
  241. if (msg.time.completed) {
  242. result.completed = msg.time.completed
  243. }
  244. }
  245. }
  246. return result
  247. })
  248. return (
  249. <main classList={{ [styles.root]: true, "not-content": true }}>
  250. <div data-component="header">
  251. <h1 data-component="header-title">{store.info?.title}</h1>
  252. <div data-component="header-details">
  253. <ul data-component="header-stats">
  254. <li title="opencode version" data-slot="item">
  255. <div data-slot="icon" title="opencode">
  256. <IconOpencode width={16} height={16} />
  257. </div>
  258. <Show when={store.info?.version} fallback="v0.0.1">
  259. <span>v{store.info?.version}</span>
  260. </Show>
  261. </li>
  262. {Object.values(data().models).length > 0 ? (
  263. <For each={Object.values(data().models)}>
  264. {([provider, model]) => (
  265. <li data-slot="item">
  266. <div data-slot="icon" title={provider}>
  267. <ProviderIcon model={model} />
  268. </div>
  269. <span data-slot="model">{model}</span>
  270. </li>
  271. )}
  272. </For>
  273. ) : (
  274. <li>
  275. <span data-element-label>Models</span>
  276. <span data-placeholder>&mdash;</span>
  277. </li>
  278. )}
  279. </ul>
  280. <div
  281. data-component="header-time"
  282. title={DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
  283. >
  284. {DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_MED)}
  285. </div>
  286. </div>
  287. </div>
  288. <div>
  289. <Show when={data().messages.length > 0} fallback={<p>Waiting for messages...</p>}>
  290. <div class={styles.parts}>
  291. <SuspenseList revealOrder="forwards">
  292. <For each={data().messages}>
  293. {(msg, msgIndex) => {
  294. const filteredParts = createMemo(() =>
  295. msg.parts.filter((x, index) => {
  296. if (x.type === "step-start" && index > 0) return false
  297. if (x.type === "step-finish") return false
  298. if (x.type === "text" && x.synthetic === true) return false
  299. if (x.type === "tool" && x.tool === "todoread") return false
  300. if (x.type === "text" && !x.text) return false
  301. if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running"))
  302. return false
  303. return true
  304. })
  305. )
  306. return (
  307. <Suspense>
  308. <For each={filteredParts()}>
  309. {(part, partIndex) => {
  310. const last = createMemo(
  311. () =>
  312. data().messages.length === msgIndex() + 1 && filteredParts().length === partIndex() + 1,
  313. )
  314. onMount(() => {
  315. const hash = window.location.hash.slice(1)
  316. // Wait till all parts are loaded
  317. if (
  318. hash !== "" &&
  319. !hasScrolledToAnchor &&
  320. filteredParts().length === partIndex() + 1 &&
  321. data().messages.length === msgIndex() + 1
  322. ) {
  323. hasScrolledToAnchor = true
  324. scrollToAnchor(hash)
  325. }
  326. })
  327. return <Part last={last()} part={part} index={partIndex()} message={msg} />
  328. }}
  329. </For>
  330. </Suspense>
  331. )
  332. }}
  333. </For>
  334. </SuspenseList>
  335. <div data-section="part" data-part-type="summary">
  336. <div data-section="decoration">
  337. <span data-status={connectionStatus()[0]}></span>
  338. </div>
  339. <div data-section="content">
  340. <p data-section="copy">{getStatusText(connectionStatus())}</p>
  341. <ul data-section="stats">
  342. <li>
  343. <span data-element-label>Cost</span>
  344. {data().cost !== undefined ? (
  345. <span>${data().cost.toFixed(2)}</span>
  346. ) : (
  347. <span data-placeholder>&mdash;</span>
  348. )}
  349. </li>
  350. <li>
  351. <span data-element-label>Input Tokens</span>
  352. {data().tokens.input ? <span>{data().tokens.input}</span> : <span data-placeholder>&mdash;</span>}
  353. </li>
  354. <li>
  355. <span data-element-label>Output Tokens</span>
  356. {data().tokens.output ? <span>{data().tokens.output}</span> : <span data-placeholder>&mdash;</span>}
  357. </li>
  358. <li>
  359. <span data-element-label>Reasoning Tokens</span>
  360. {data().tokens.reasoning ? (
  361. <span>{data().tokens.reasoning}</span>
  362. ) : (
  363. <span data-placeholder>&mdash;</span>
  364. )}
  365. </li>
  366. </ul>
  367. </div>
  368. </div>
  369. </div>
  370. </Show>
  371. </div>
  372. <Show when={debug}>
  373. <div style={{ margin: "2rem 0" }}>
  374. <div
  375. style={{
  376. border: "1px solid #ccc",
  377. padding: "1rem",
  378. "overflow-y": "auto",
  379. }}
  380. >
  381. <Show when={data().messages.length > 0} fallback={<p>Waiting for messages...</p>}>
  382. <ul style={{ "list-style-type": "none", padding: 0 }}>
  383. <For each={data().messages}>
  384. {(msg) => (
  385. <li
  386. style={{
  387. padding: "0.75rem",
  388. margin: "0.75rem 0",
  389. "box-shadow": "0 1px 3px rgba(0,0,0,0.1)",
  390. }}
  391. >
  392. <div>
  393. <strong>Key:</strong> {msg.id}
  394. </div>
  395. <pre>{JSON.stringify(msg, null, 2)}</pre>
  396. </li>
  397. )}
  398. </For>
  399. </ul>
  400. </Show>
  401. </div>
  402. </div>
  403. </Show>
  404. <Show when={showScrollButton()}>
  405. <button
  406. type="button"
  407. class={styles["scroll-button"]}
  408. onClick={() => document.body.scrollIntoView({ behavior: "smooth", block: "end" })}
  409. onMouseEnter={() => {
  410. setIsButtonHovered(true)
  411. if (scrollTimeout) {
  412. clearTimeout(scrollTimeout)
  413. }
  414. }}
  415. onMouseLeave={() => {
  416. setIsButtonHovered(false)
  417. if (showScrollButton()) {
  418. scrollTimeout = window.setTimeout(() => {
  419. if (!isButtonHovered()) {
  420. setShowScrollButton(false)
  421. }
  422. }, 3000)
  423. }
  424. }}
  425. title="Scroll to bottom"
  426. aria-label="Scroll to bottom"
  427. >
  428. <IconArrowDown width={20} height={20} />
  429. </button>
  430. </Show>
  431. </main>
  432. )
  433. }
  434. export function fromV1(v1: Message.Info): MessageWithParts {
  435. if (v1.role === "assistant") {
  436. return {
  437. id: v1.id,
  438. sessionID: v1.metadata.sessionID,
  439. role: "assistant",
  440. time: {
  441. created: v1.metadata.time.created,
  442. completed: v1.metadata.time.completed,
  443. },
  444. cost: v1.metadata.assistant!.cost,
  445. path: v1.metadata.assistant!.path,
  446. summary: v1.metadata.assistant!.summary,
  447. tokens: v1.metadata.assistant!.tokens,
  448. modelID: v1.metadata.assistant!.modelID,
  449. providerID: v1.metadata.assistant!.providerID,
  450. system: v1.metadata.assistant!.system,
  451. error: v1.metadata.error,
  452. parts: v1.parts.flatMap((part, index): MessageV2.Part[] => {
  453. const base = {
  454. id: index.toString(),
  455. messageID: v1.id,
  456. sessionID: v1.metadata.sessionID,
  457. }
  458. if (part.type === "text") {
  459. return [
  460. {
  461. ...base,
  462. type: "text",
  463. text: part.text,
  464. },
  465. ]
  466. }
  467. if (part.type === "step-start") {
  468. return [
  469. {
  470. ...base,
  471. type: "step-start",
  472. },
  473. ]
  474. }
  475. if (part.type === "tool-invocation") {
  476. return [
  477. {
  478. ...base,
  479. type: "tool",
  480. callID: part.toolInvocation.toolCallId,
  481. tool: part.toolInvocation.toolName,
  482. state: (() => {
  483. if (part.toolInvocation.state === "partial-call") {
  484. return {
  485. status: "pending",
  486. }
  487. }
  488. const { title, time, ...metadata } = v1.metadata.tool[part.toolInvocation.toolCallId]
  489. if (part.toolInvocation.state === "call") {
  490. return {
  491. status: "running",
  492. input: part.toolInvocation.args,
  493. time: {
  494. start: time.start,
  495. },
  496. }
  497. }
  498. if (part.toolInvocation.state === "result") {
  499. return {
  500. status: "completed",
  501. input: part.toolInvocation.args,
  502. output: part.toolInvocation.result,
  503. title,
  504. time,
  505. metadata,
  506. }
  507. }
  508. throw new Error("unknown tool invocation state")
  509. })(),
  510. },
  511. ]
  512. }
  513. return []
  514. }),
  515. }
  516. }
  517. if (v1.role === "user") {
  518. return {
  519. id: v1.id,
  520. sessionID: v1.metadata.sessionID,
  521. role: "user",
  522. time: {
  523. created: v1.metadata.time.created,
  524. },
  525. parts: v1.parts.flatMap((part, index): MessageV2.Part[] => {
  526. const base = {
  527. id: index.toString(),
  528. messageID: v1.id,
  529. sessionID: v1.metadata.sessionID,
  530. }
  531. if (part.type === "text") {
  532. return [
  533. {
  534. ...base,
  535. type: "text",
  536. text: part.text,
  537. },
  538. ]
  539. }
  540. if (part.type === "file") {
  541. return [
  542. {
  543. ...base,
  544. type: "file",
  545. mime: part.mediaType,
  546. filename: part.filename,
  547. url: part.url,
  548. },
  549. ]
  550. }
  551. return []
  552. }),
  553. }
  554. }
  555. throw new Error("unknown message type")
  556. }