diff options
| author | Jay V <[email protected]> | 2025-05-26 17:25:06 -0400 |
|---|---|---|
| committer | Jay V <[email protected]> | 2025-05-26 17:25:12 -0400 |
| commit | 66b18959ebc7b699a74ce69d3adfb4c4dcaa5fd1 (patch) | |
| tree | 5ef9340b1c496076dd1e12156fdc35c3f3c35933 /app/packages/web/src/components | |
| parent | deacf5991abfb777aae7823a8e7e352fbefdabd0 (diff) | |
| download | opencode-66b18959ebc7b699a74ce69d3adfb4c4dcaa5fd1.tar.gz opencode-66b18959ebc7b699a74ce69d3adfb4c4dcaa5fd1.zip | |
Merging docs and share app
Diffstat (limited to 'app/packages/web/src/components')
| -rw-r--r-- | app/packages/web/src/components/Header.astro | 57 | ||||
| -rw-r--r-- | app/packages/web/src/components/Hero.astro | 11 | ||||
| -rw-r--r-- | app/packages/web/src/components/Lander.astro | 269 | ||||
| -rw-r--r-- | app/packages/web/src/components/Share.tsx | 691 |
4 files changed, 1028 insertions, 0 deletions
diff --git a/app/packages/web/src/components/Header.astro b/app/packages/web/src/components/Header.astro new file mode 100644 index 000000000..f027d7274 --- /dev/null +++ b/app/packages/web/src/components/Header.astro @@ -0,0 +1,57 @@ +--- +import config from 'virtual:starlight/user-config'; +import { Icon } from '@astrojs/starlight/components'; +import { HeaderLinks } from 'toolbeam-docs-theme/components'; +import Default from 'toolbeam-docs-theme/overrides/Header.astro'; +import SiteTitle from '@astrojs/starlight/components/SiteTitle.astro'; + +const path = Astro.url.pathname; + +const links = config.social || []; +--- + +{ path.startsWith("/share") + ? <div class="header sl-flex"> + <div class="title-wrapper sl-flex"> + <SiteTitle {...Astro.props} /> + </div> + <div class="middle-group sl-flex"> + <HeaderLinks {...Astro.props} /> + </div> + </div> + : <Default {...Astro.props}><slot /></Default> +} + +<style> + .header { + justify-content: space-between; + align-items: center; + height: 100%; + } + + .title-wrapper { + /* Prevent long titles overflowing and covering the search and menu buttons on narrow viewports. */ + overflow: clip; + /* Avoid clipping focus ring around link inside title wrapper. */ + padding: calc(0.25rem + 2px) 0.25rem calc(0.25rem - 2px); + margin: -0.25rem; + } + + .middle-group { + justify-content: flex-end; + gap: var(--sl-nav-gap); + } + @media (max-width: 50rem) { + :global(:root[data-has-sidebar]) { + .middle-group { + display: none; + } + } + } + @media (min-width: 50rem) { + .middle-group { + display: flex; + } + } +</style> + diff --git a/app/packages/web/src/components/Hero.astro b/app/packages/web/src/components/Hero.astro new file mode 100644 index 000000000..f80f85266 --- /dev/null +++ b/app/packages/web/src/components/Hero.astro @@ -0,0 +1,11 @@ +--- +import Default from '@astrojs/starlight/components/Hero.astro'; +import Lander from './Lander.astro'; + +const { slug } = Astro.locals.starlightRoute.entry; +--- + +{ slug === "" + ? <Lander {...Astro.props} /> + : <Default {...Astro.props}><slot /></Default> +} diff --git a/app/packages/web/src/components/Lander.astro b/app/packages/web/src/components/Lander.astro new file mode 100644 index 000000000..d27358f8f --- /dev/null +++ b/app/packages/web/src/components/Lander.astro @@ -0,0 +1,269 @@ +--- +import { Image } from 'astro:assets'; +import config from "virtual:starlight/user-config"; +import type { Props } from '@astrojs/starlight/props'; + +import CopyIcon from "../assets/lander/copy.svg"; +import CheckIcon from "../assets/lander/check.svg"; + +const { data } = Astro.locals.starlightRoute.entry; +const { title = data.title, tagline, image, actions = [] } = data.hero || {}; + +const imageAttrs = { + loading: 'eager' as const, + decoding: 'async' as const, + width: 400, + alt: image?.alt || '', +}; + +const github = config.social.filter(s => s.icon === 'github')[0]; + +const command = "npm i -g"; +const pkg = "opencode"; + +let darkImage: ImageMetadata | undefined; +let lightImage: ImageMetadata | undefined; +let rawHtml: string | undefined; +if (image) { + if ('file' in image) { + darkImage = image.file; + } else if ('dark' in image) { + darkImage = image.dark; + lightImage = image.light; + } else { + rawHtml = image.html; + } +} +--- +<div class="hero"> + <section class="top"> + <div class="logo"> + <Image + src={darkImage} + {...imageAttrs} + class:list={{ 'light:sl-hidden': Boolean(lightImage) }} + /> + <Image src={lightImage} {...imageAttrs} class="dark:sl-hidden" /> + </div> + <h1>The AI coding agent built for the terminal.</h1> + </section> + + <section class="cta"> + <div class="col1"> + <a href="/docs">View the docs</a> + </div> + <div class="col2"> + <button class="command" data-command={`${command} ${pkg}`}> + <code>{command} <span class="highlight">{pkg}</span></code> + <span class="copy"> + <CopyIcon /> + <CheckIcon /> + </span> + </button> + </div> + <div class="col3"> + <a href={github.href}>Star on GitHub</a> + </div> + </section> + + <section class="content"> + <ul> + <li><b>Native TUI</b>: A native terminal UI for a smoother, snappier experience.</li> + <li><b>LSP enabled</b>: Loads the right LSPs for your codebase. Helps the LLM make fewer mistakes.</li> + <li><b>Multi-session</b>: Start multiple conversations in a project to have agents working in parallel.</li> + <li><b>Use any model</b>: Supports all the models from OpenAI, Anthropic, Google, OpenRouter, and more.</li> + <li><b>Change tracking</b>: View the file changes from the current conversation in the sidebar.</li> + <li><b>Edit with Vim</b>: Use Vim as an external editor to compose longer messages.</li> + </ul> + </section> + + <section class="footer"> + <div class="col1"> + <span>Version: Beta</span> + </div> + <div class="col2"> + <span>Author: <a href="https://sst.dev">SST</a></span> + </div> + </section> +</div> + +<style> +.hero { + --padding: 3rem; + --vertical-padding: 2rem; + --heading-font-size: var(--sl-text-3xl); + + margin: 1rem; + border: 2px solid var(--sl-color-white); +} +@media (max-width: 30rem) { + .hero { + --padding: 1rem; + --vertical-padding: 1rem; + --heading-font-size: var(--sl-text-2xl); + + margin: 0.5rem; + } +} + +section.top { + padding: var(--padding); + + h1 { + margin-top: calc(var(--vertical-padding) / 8); + font-size: var(--heading-font-size); + line-height: 1.25; + text-transform: uppercase; + } + + img { + height: auto; + width: clamp(200px, 70vw, 400px); + } +} + +section.cta { + display: flex; + flex-direction: row; + justify-content: space-between; + border-top: 2px solid var(--sl-color-white); + + & > div { + flex: 1; + line-height: 1.4; + padding: calc(var(--padding) / 2) 0.5rem; + } + & > div:not(.col2) { + text-align: center; + text-transform: uppercase; + } + + @media (max-width: 30rem) { + & > div { + padding-bottom: calc(var(--padding) / 2 + 4px); + } + } + + & > div + div { + border-left: 2px solid var(--sl-color-white); + } + + .command { + all: unset; + display: flex; + align-items: center; + gap: 0.625rem; + justify-content: center; + cursor: pointer; + width: 100%; + + code { + color: var(--sl-color-text-secondary); + font-size: 1.125rem; + } + code .highlight { + color: var(--sl-color-text); + font-weight: 500; + } + + .copy { + line-height: 1; + padding: 0; + } + .copy svg { + width: 1rem; + height: 1rem; + vertical-align: middle; + } + .copy svg:first-child { + color: var(--sl-color-text-dimmed); + } + .copy svg:last-child { + color: var(--sl-color-text); + display: none; + } + &.success .copy { + pointer-events: none; + } + &.success .copy svg:first-child { + display: none; + } + &.success .copy svg:last-child { + display: inline; + } + } +} + +section.content { + border-top: 2px solid var(--sl-color-white); + padding: var(--padding); + + ul { + padding-left: 1rem; + + li + li { + margin-top: calc(var(--vertical-padding) / 2); + } + + li b { + text-transform: uppercase; + } + } +} + +section.approach { + border-top: 2px solid var(--sl-color-white); + padding: var(--padding); + + p + p { + margin-top: var(--vertical-padding); + } +} + +section.footer { + border-top: 2px solid var(--sl-color-white); + display: flex; + flex-direction: row; + + & > div { + flex: 1; + text-align: center; + text-transform: uppercase; + padding: calc(var(--padding) / 2) 0.5rem; + } + + & > div + div { + border-left: 2px solid var(--sl-color-white); + } +} +</style> + +<style is:global> +:root[data-has-hero] { + header.header { + display: none; + } + .main-frame { + padding-top: 0; + + .main-pane > main { + padding: 0; + } + } + main > .content-panel .sl-markdown-content { + margin-top: 0; + } +} +</style> + +<script> + const button = document.querySelector("button.command") as HTMLButtonElement; + + button?.addEventListener("click", () => { + navigator.clipboard.writeText(button.dataset.command!); + button.classList.toggle("success"); + setTimeout(() => { + button.classList.toggle("success"); + }, 1500); + }); +</script> diff --git a/app/packages/web/src/components/Share.tsx b/app/packages/web/src/components/Share.tsx new file mode 100644 index 000000000..906a9ab9c --- /dev/null +++ b/app/packages/web/src/components/Share.tsx @@ -0,0 +1,691 @@ +import { createSignal, onCleanup, onMount, Show, For } from "solid-js" +import { type UIMessage } from "ai" + +type Message = { + key: string + content: string +} + +type SessionInfo = { + tokens?: { + input?: number + output?: number + reasoning?: number + } +} + +export default function Share(props: { api: string }) { + let params = new URLSearchParams(document.location.search) + const shareId = params.get("id") + + const [connectionStatus, setConnectionStatus] = createSignal("Disconnected") + const [sessionInfo, setSessionInfo] = createSignal<SessionInfo | null>(null) + const [systemMessage, setSystemMessage] = createSignal<Message | null>(null) + const [messages, setMessages] = createSignal<Message[]>([]) + const [expandedSystemMessage, setExpandedSystemMessage] = createSignal(false) + + onMount(() => { + const apiUrl = props.api + + console.log("Mounting Share component with ID:", shareId) + console.log("API URL:", apiUrl) + + if (!shareId) { + console.error("Share ID not found in environment variables") + setConnectionStatus("Error: Share ID not found") + return + } + + if (!apiUrl) { + console.error("API URL not found in environment variables") + setConnectionStatus("Error: API URL not found") + return + } + + let reconnectTimer: number | undefined + let socket: WebSocket | null = null + + // Function to create and set up WebSocket with auto-reconnect + const setupWebSocket = () => { + // Close any existing connection + if (socket) { + socket.close() + } + + setConnectionStatus("Connecting...") + + // Always use secure WebSocket protocol (wss) + const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://") + const wsUrl = `${wsBaseUrl}/share_poll?shareID=${shareId}` + console.log("Connecting to WebSocket URL:", wsUrl) + + // Create WebSocket connection + socket = new WebSocket(wsUrl) + + // Handle connection opening + socket.onopen = () => { + setConnectionStatus("Connected") + console.log("WebSocket connection established") + } + + // Handle incoming messages + socket.onmessage = (event) => { + console.log("WebSocket message received") + try { + const data = JSON.parse(event.data) as Message + + // Check if this is a session info message + if (data.key.startsWith("session/info/")) { + const infoContent = JSON.parse(data.content) as SessionInfo + setSessionInfo(infoContent) + console.log("Session info updated:", infoContent) + return + } + + // Check if it's a system message + const msgContent = JSON.parse(data.content) as UIMessage + if (msgContent.role === "system") { + setSystemMessage(data) + console.log("System message updated:", data) + return + } + + // Non-system messages + setMessages((prev) => { + // Check if message with this key already exists + const existingIndex = prev.findIndex((msg) => msg.key === data.key) + if (existingIndex >= 0) { + // Update existing message + const updated = [...prev] + updated[existingIndex] = data + return updated + } else { + // Add new message + return [...prev, data] + } + }) + } catch (error) { + console.error("Error parsing WebSocket message:", error) + } + } + + // Handle errors + socket.onerror = (error) => { + console.error("WebSocket error:", error) + setConnectionStatus("Error: Connection failed") + } + + // Handle connection close and reconnection + socket.onclose = (event) => { + console.log(`WebSocket closed: ${event.code} ${event.reason}`) + setConnectionStatus("Disconnected, reconnecting...") + + // Try to reconnect after 2 seconds + clearTimeout(reconnectTimer) + reconnectTimer = window.setTimeout( + setupWebSocket, + 2000, + ) as unknown as number + } + } + + // Initial connection + setupWebSocket() + + // Clean up on component unmount + onCleanup(() => { + console.log("Cleaning up WebSocket connection") + if (socket) { + socket.close() + } + clearTimeout(reconnectTimer) + }) + }) + + return ( + <main> + <h1>Share: {shareId}</h1> + + <div style={{ margin: "2rem 0" }}> + <h2>WebSocket Connection</h2> + <p> + Status: <strong>{connectionStatus()}</strong> + </p> + + <h3>Live Updates</h3> + + <Show when={sessionInfo()}> + <div + style={{ + padding: "1rem", + marginBottom: "1rem", + border: "1px solid #dee2e6", + }} + > + <h4 style={{ margin: "0 0 0.75rem 0" }}>Session Information</h4> + <div style={{ display: "flex", gap: "1.5rem" }}> + <div> + <strong>Input Tokens:</strong>{" "} + {sessionInfo()?.tokens?.input || 0} + </div> + <div> + <strong>Output Tokens:</strong>{" "} + {sessionInfo()?.tokens?.output || 0} + </div> + <div> + <strong>Reasoning Tokens:</strong>{" "} + {sessionInfo()?.tokens?.reasoning || 0} + </div> + </div> + </div> + </Show> + + {/* Display system message as context in the Session Information block */} + <Show when={systemMessage()}> + <div + style={{ + padding: "1rem", + marginBottom: "1rem", + border: "1px solid #dee2e6", + }} + > + <h4 style={{ margin: "0 0 0.75rem 0" }}>Context</h4> + {(() => { + try { + const parsed = JSON.parse( + systemMessage()?.content || "", + ) as UIMessage + if ( + parsed.parts && + parsed.parts.length > 0 && + parsed.parts[0].type === "text" + ) { + const text = parsed.parts[0].text || "" + const lines = text.split("\n") + const visibleLines = expandedSystemMessage() + ? lines + : lines.slice(0, 5) + const hasMoreLines = lines.length > 5 + + return ( + <> + <div + style={{ + padding: "0.75rem", + border: "1px solid #dee2e6", + }} + > + {/* Create a modified version of the text part for the system message */} + {(() => { + // Create a modified part with truncated text + const modifiedPart = { + ...parsed.parts[0], + text: visibleLines.join("\n"), + } + + return ( + <> + <pre>{modifiedPart.text}</pre> + {hasMoreLines && !expandedSystemMessage() && ( + <div + style={{ + color: "#6c757d", + fontStyle: "italic", + marginTop: "0.5rem", + }} + > + {lines.length - 5} more lines... + </div> + )} + </> + ) + })()} + </div> + {hasMoreLines && ( + <button + onClick={() => + setExpandedSystemMessage(!expandedSystemMessage()) + } + style={{ + marginTop: "0.5rem", + padding: "0.25rem 0.75rem", + border: "1px solid #ced4da", + cursor: "pointer", + fontSize: "0.875rem", + }} + > + {expandedSystemMessage() ? "Show Less" : "Show More"} + </button> + )} + </> + ) + } + } catch (e) { + return <div>Error parsing system message</div> + } + + return null + })()} + </div> + </Show> + + <div + style={{ + border: "1px solid #ccc", + padding: "1rem", + maxHeight: "500px", + overflowY: "auto", + }} + > + <Show + when={messages().length > 0} + fallback={<p>Waiting for messages...</p>} + > + <ul style={{ listStyleType: "none", padding: 0 }}> + <For each={messages()}> + {(msg) => ( + <li + style={{ + padding: "0.75rem", + margin: "0.75rem 0", + boxShadow: "0 1px 3px rgba(0,0,0,0.1)", + }} + > + <div> + <strong>Key:</strong> {msg.key} + </div> + + {(() => { + try { + const parsed = JSON.parse(msg.content) as UIMessage + const createdTime = parsed.metadata?.time?.created + ? new Date( + parsed.metadata.time.created, + ).toLocaleString() + : "Unknown time" + + return ( + <> + <div style={{ marginTop: "0.5rem" }}> + <strong>Full Content:</strong> + <pre + style={{ + padding: "0.5rem", + overflow: "auto", + maxHeight: "150px", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + fontSize: "0.85rem", + }} + > + {JSON.stringify(parsed, null, 2)} + </pre> + </div> + + {parsed.parts && parsed.parts.length > 0 && ( + <div style={{ marginTop: "0.75rem" }}> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "0.25rem 0.5rem", + marginBottom: "0.5rem", + }} + > + <strong> + Role: {parsed.role || "Unknown"} + </strong> + <span + style={{ + fontSize: "0.8rem", + color: "#6c757d", + }} + > + {createdTime} + </span> + </div> + + <div + style={{ + padding: "0.75rem", + border: "1px solid #dee2e6", + }} + > + <For + each={parsed.parts.filter( + (part) => part.type !== "step-start", + )} + > + {(part) => { + if (part.type === "text") { + //{ + // "type": "text", + // "text": "Hello! How can I help you today?" + //} + return ( + <pre> + [{part.type}] {part.text}{" "} + </pre> + ) + } + if (part.type === "reasoning") { + //{ + // "type": "reasoning", + // "text": "The user asked for a weather forecast. I should call the 'getWeather' tool with the location 'San Francisco'.", + // "providerMetadata": { "step_id": "reason_step_1" } + //} + return ( + <pre> + [{part.type}] {part.text} + </pre> + ) + } + if (part.type === "tool-invocation") { + return ( + <div> + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "0.3rem", + }} + > + <span> + <pre + style={{ + margin: 0, + display: "inline", + }} + > + [{part.type}] + </pre>{" "} + Tool:{" "} + <strong> + {part.toolInvocation.toolName} + </strong> + </span> + {parsed.metadata?.tool?.[ + part.toolInvocation.toolCallId + ]?.time?.start && + parsed.metadata?.tool?.[ + part.toolInvocation.toolCallId + ]?.time?.end && ( + <span + style={{ + color: "#6c757d", + fontSize: "0.8rem", + }} + > + {( + (new Date( + parsed.metadata?.tool?.[ + part.toolInvocation.toolCallId + ].time.end, + ) - + new Date( + parsed.metadata?.tool?.[ + part.toolInvocation.toolCallId + ].time.start, + )) / + 1000 + ).toFixed(2)} + s + </span> + )} + </div> + {(() => { + if ( + part.toolInvocation.state === + "partial-call" + ) { + //{ + // "type": "tool-invocation", + // "toolInvocation": { + // "state": "partial-call", + // "toolCallId": "tool_abc123", + // "toolName": "searchWeb", + // "argsTextDelta": "{\"query\":\"latest AI news" + // } + //} + return ( + <> + <pre> + { + part.toolInvocation + .argsTextDelta + } + </pre> + <span>...</span> + </> + ) + } + if ( + part.toolInvocation.state === + "call" + ) { + //{ + // "type": "tool-invocation", + // "toolInvocation": { + // "state": "call", + // "toolCallId": "tool_abc123", + // "toolName": "searchWeb", + // "args": { "query": "latest AI news", "count": 3 } + // } + //} + return ( + <pre> + {JSON.stringify( + part.toolInvocation.args, + null, + 2, + )} + </pre> + ) + } + if ( + part.toolInvocation.state === + "result" + ) { + //{ + // "type": "tool-invocation", + // "toolInvocation": { + // "state": "result", + // "toolCallId": "tool_abc123", + // "toolName": "searchWeb", + // "args": { "query": "latest AI news", "count": 3 }, + // "result": [ + // { "title": "AI SDK v5 Announced", "url": "..." }, + // { "title": "New LLM Achieves SOTA", "url": "..." } + // ] + // } + //} + return ( + <> + <pre> + {JSON.stringify( + part.toolInvocation + .args, + null, + 2, + )} + </pre> + <pre> + {JSON.stringify( + part.toolInvocation + .result, + null, + 2, + )} + </pre> + </> + ) + } + if ( + part.toolInvocation.state === + "error" + ) { + //{ + // "type": "tool-invocation", + // "toolInvocation": { + // "state": "error", + // "toolCallId": "tool_abc123", + // "toolName": "searchWeb", + // "args": { "query": "latest AI news", "count": 3 }, + // "errorMessage": "API limit exceeded for searchWeb tool." + // } + //} + return ( + <> + <pre> + {JSON.stringify( + part.toolInvocation + .args, + null, + 2, + )} + </pre> + <pre> + { + part.toolInvocation + .errorMessage + } + </pre> + </> + ) + } + })()} + </div> + ) + } + if (part.type === "source") { + //{ + // "type": "source", + // "source": { + // "sourceType": "url", + // "id": "doc_xyz789", + // "url": "https://example.com/research-paper.pdf", + // "title": "Groundbreaking AI Research Paper" + // } + //} + return ( + <div> + <div> + <span> + <pre>[{part.type}]</pre> + </span> + <span> + Source:{" "} + {part.source.title || + part.source.id} + </span> + </div> + {part.source.url && ( + <div> + <a + href={part.source.url} + target="_blank" + rel="noopener noreferrer" + style={{ color: "#0c5460" }} + > + {part.source.url} + </a> + </div> + )} + {part.source.sourceType && ( + <div> + Type: {part.source.sourceType} + </div> + )} + </div> + ) + } + if (part.type === "file") { + //{ + // "type": "file", + // "mediaType": "image/jpeg", + // "filename": "cat_photo.jpg", + // "url": "https://example-files.com/cats/cat_photo.jpg" + //} + const isImage = + part.mediaType?.startsWith("image/") + + return ( + <div> + <div> + <span> + <pre>[{part.type}]</pre> + </span> + <span>File: {part.filename}</span> + <span>{part.mediaType}</span> + </div> + + {isImage && part.url ? ( + <div> + <img + src={part.url} + alt={ + part.filename || + "Attached image" + } + /> + </div> + ) : ( + <div> + {part.url ? ( + <a + href={part.url} + target="_blank" + rel="noopener noreferrer" + > + Download: {part.filename} + </a> + ) : ( + <div> + File attachment (no URL + available) + </div> + )} + </div> + )} + </div> + ) + } + return null + }} + </For> + </div> + </div> + )} + </> + ) + } catch (e) { + return ( + <div> + <strong>Content:</strong> + <pre + style={{ + padding: "0.5rem", + overflow: "auto", + maxHeight: "200px", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + }} + > + {msg.content} + </pre> + </div> + ) + } + })()} + </li> + )} + </For> + </ul> + </Show> + </div> + </div> + </main> + ) +} |
