summaryrefslogtreecommitdiffhomepage
path: root/app/packages/web/src/components
diff options
context:
space:
mode:
authorJay V <[email protected]>2025-05-26 17:25:06 -0400
committerJay V <[email protected]>2025-05-26 17:25:12 -0400
commit66b18959ebc7b699a74ce69d3adfb4c4dcaa5fd1 (patch)
tree5ef9340b1c496076dd1e12156fdc35c3f3c35933 /app/packages/web/src/components
parentdeacf5991abfb777aae7823a8e7e352fbefdabd0 (diff)
downloadopencode-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.astro57
-rw-r--r--app/packages/web/src/components/Hero.astro11
-rw-r--r--app/packages/web/src/components/Lander.astro269
-rw-r--r--app/packages/web/src/components/Share.tsx691
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}&nbsp;<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>
+ )
+}