Share.tsx 20 KB

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