Share.tsx 22 KB

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