Share.tsx 20 KB

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