diff options
Diffstat (limited to 'packages/web/src/components')
| -rw-r--r-- | packages/web/src/components/Footer.astro | 125 | ||||
| -rw-r--r-- | packages/web/src/components/Head.astro | 16 | ||||
| -rw-r--r-- | packages/web/src/components/Header.astro | 228 | ||||
| -rw-r--r-- | packages/web/src/components/Lander.astro | 62 | ||||
| -rw-r--r-- | packages/web/src/components/Share.tsx | 437 | ||||
| -rw-r--r-- | packages/web/src/components/SiteTitle.astro | 2 | ||||
| -rw-r--r-- | packages/web/src/components/share/common.tsx | 71 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-bash.tsx | 5 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-code.tsx | 2 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-error.tsx | 5 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-markdown.tsx | 7 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-text.tsx | 5 | ||||
| -rw-r--r-- | packages/web/src/components/share/copy-button.tsx | 10 | ||||
| -rw-r--r-- | packages/web/src/components/share/part.tsx | 154 |
14 files changed, 694 insertions, 435 deletions
diff --git a/packages/web/src/components/Footer.astro b/packages/web/src/components/Footer.astro new file mode 100644 index 000000000..20d65ee9d --- /dev/null +++ b/packages/web/src/components/Footer.astro @@ -0,0 +1,125 @@ +--- +import config from "virtual:starlight/user-config" +import LanguageSelect from "@astrojs/starlight/components/LanguageSelect.astro" +import { Icon } from "@astrojs/starlight/components" + +const { lang, editUrl, lastUpdated, entry } = Astro.locals.starlightRoute +const template = entry.data.template +const issueLink = Astro.locals.t("app.footer.issueLink", "Found a bug? Open an issue") +const discordLink = Astro.locals.t("app.footer.discordLink", "Join our Discord community") + +const github = config.social?.find((item) => item.icon === "github") +const discord = config.social?.find((item) => item.icon === "discord") +--- + +{ + template === "doc" && ( + <footer class="doc"> + <div class="meta sl-flex"> + <div> + { + editUrl && ( + <a href={editUrl} target="_blank" rel="noopener noreferrer" class="sl-flex"> + <Icon name="pencil" size="1em" /> + {Astro.locals.t("page.editLink")} + </a> + ) + } + { + github && ( + <a href={`${github.href}/issues/new`} target="_blank" rel="noopener noreferrer" class="sl-flex"> + <Icon name={github.icon} size="1em" /> + {issueLink} + </a> + ) + } + { + discord && ( + <a href={discord.href} target="_blank" rel="noopener noreferrer" class="sl-flex"> + <Icon name={discord.icon} size="1em" /> + {discordLink} + </a> + ) + } + <LanguageSelect /> + </div> + <div> + <p>© <a target="_blank" rel="noopener noreferrer" href="https://anoma.ly">Anomaly</a></p> + <p title={Astro.locals.t("page.lastUpdated")}> + {Astro.locals.t("page.lastUpdated")} {" "} + { + lastUpdated ? ( + <time datetime={lastUpdated.toISOString()}> + {lastUpdated.toLocaleDateString(lang, { dateStyle: "medium", timeZone: "UTC" })} + </time> + ) : ( + "-" + ) + } + </p> + </div> + </div> + </footer> + ) +} + +<style> + footer.doc { + margin-top: 3rem; + border-top: 1px solid var(--sl-color-border); + } + + .meta { + gap: 0.75rem 3rem; + justify-content: space-between; + flex-wrap: wrap; + margin-block: 3rem 1.5rem; + font-size: var(--sl-text-sm); + } + + @media (min-width: 30rem) { + .meta { + flex-direction: row; + } + } + + .doc a, + .doc p { + padding-block: 0.125rem; + } + + .doc a { + gap: 0.4375rem; + align-items: center; + text-decoration: none; + color: var(--sl-color-text); + font-size: var(--sl-text-sm); + } + + .doc a svg { + opacity: 0.85; + } + + .doc p { + color: var(--sl-color-text-dimmed); + } + + .doc :global(starlight-lang-select) { + display: inline-flex; + margin-top: 0.5rem; + } + + .doc :global(starlight-lang-select select) { + min-width: 7em; + } + + @media (min-width: 30rem) { + .doc p { + text-align: right; + } + } + + .doc p a { + color: var(--sl-color-text-dimmed); + } +</style> diff --git a/packages/web/src/components/Head.astro b/packages/web/src/components/Head.astro index 0eaf115b5..4b35d26db 100644 --- a/packages/web/src/components/Head.astro +++ b/packages/web/src/components/Head.astro @@ -1,10 +1,9 @@ --- import { Base64 } from "js-base64"; -import type { Props } from '@astrojs/starlight/props' import Default from '@astrojs/starlight/components/Head.astro' import config from '../../config.mjs' -const base = import.meta.env.BASE_URL.slice(1) +const base = import.meta.env.BASE_URL.replace(/^\//, "").replace(/\/$/, "") const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, ""); const { @@ -12,7 +11,12 @@ const { data: { title , description }, }, } = Astro.locals.starlightRoute; -const isDocs = slug.startsWith("docs") +const isDocs = base === "" ? true : slug === base || slug.startsWith(`${base}/`) +const t = Astro.locals.t as (key: string) => string +const titleSuffix = t("app.head.titleSuffix") +const shareSlug = base === "" ? "s" : `${base}/s` +const isShare = slug === shareSlug || slug.startsWith(`${shareSlug}/`) +const isHome = slug === "" || slug === base let encodedTitle = ''; let ogImage = `${config.url}/social-share.png`; @@ -38,13 +42,13 @@ if (isDocs) { } --- -{ slug === "" && ( -<title>{title} | AI coding agent built for the terminal</title> +{ isHome && ( +<title>{title} | {titleSuffix}</title> )} <Default {...Astro.props}><slot /></Default> -{ (!slug.startsWith(`${base}/s`)) && ( +{ !isShare && ( <meta property="og:image" content={ogImage} /> <meta property="twitter:image" content={ogImage} /> )} diff --git a/packages/web/src/components/Header.astro b/packages/web/src/components/Header.astro index 396200a3e..bb13c9117 100644 --- a/packages/web/src/components/Header.astro +++ b/packages/web/src/components/Header.astro @@ -1,128 +1,136 @@ --- -import config from '../../config.mjs'; -import astroConfig 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 SocialIcons from 'virtual:starlight/components/SocialIcons'; -import SiteTitle from '@astrojs/starlight/components/SiteTitle.astro'; +import config from "../../config.mjs" +import astroConfig from "virtual:starlight/user-config" +import { getRelativeLocaleUrl } from "astro:i18n" +import { Icon } from "@astrojs/starlight/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 = astroConfig.social || []; -const headerLinks = config.headerLinks; +const path = Astro.url.pathname +const locale = Astro.currentLocale || "root" +const route = Astro.locals.starlightRoute +const t = Astro.locals.t as (key: string) => string +const links = astroConfig.social || [] +const headerLinks = config.headerLinks +const sharePath = /\/s(\/|$)/.test(path) +function href(url: string) { + if (url === "/" || url === "/docs" || url === "/docs/") { + return getRelativeLocaleUrl(locale, "") + } + return url +} --- -{ path.startsWith("/s") -? <div class="header sl-flex"> - <div class="title-wrapper sl-flex"> - <SiteTitle {...Astro.props} /> - </div> - <div class="middle-group sl-flex"> - { - headerLinks?.map(({ name, url }) => ( - <a class="links" href={url}>{name}</a> - )) - } - </div> - <div class="sl-hidden md:sl-flex right-group"> - { - links.length > 0 && ( - <div class="sl-flex social-icons"> - {links.map(({ href, icon }) => ( - <a {href} rel="me" target="_blank"> - <Icon name={icon} size="1rem" /> - </a> - ))} - </div> - ) - } - </div> -</div> - : <Default {...Astro.props}><slot /></Default> -} +{sharePath ? ( + <div class="header sl-flex"> + <div class="title-wrapper sl-flex"> + <SiteTitle {...route} /> + </div> + <div class="middle-group sl-flex"> + {headerLinks?.map(({ name, url }) => ( + <a class="links" href={href(url)}>{t(name)}</a> + ))} + </div> + <div class="sl-hidden md:sl-flex right-group"> + {links.length > 0 && ( + <div class="sl-flex social-icons"> + {links.map(({ href, icon }) => ( + <a {href} rel="me" target="_blank"> + <Icon name={icon} size="1rem" /> + </a> + ))} + </div> + )} + </div> + </div> +) : ( + <Default {...route} /> +)} + <style> - .header { - gap: var(--sl-nav-gap); - justify-content: space-between; - align-items: center; - height: 100%; - } + .header { + gap: var(--sl-nav-gap); + 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. */ + .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; - } + 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; - } - } + .middle-group { + justify-content: flex-end; + gap: var(--sl-nav-gap); + } - .right-group, - .social-icons { - gap: 1rem; - align-items: center; + @media (max-width: 50rem) { + :global(:root[data-has-sidebar]) { + .middle-group { + display: none; + } + } + } + + @media (min-width: 50rem) { + .middle-group { + display: flex; + } + } + + .right-group, + .social-icons { + gap: 1rem; + align-items: center; a { - line-height: 1; + line-height: 1; - svg { - color: var(--sl-color-text-dimmed); - } + svg { + color: var(--sl-color-text-dimmed); } - a.links { - text-transform: uppercase; - font-size: var(--sl-text-sm); - color: var(--sl-color-text-secondary); - line-height: normal; + } + + a.links { + text-transform: uppercase; + font-size: var(--sl-text-sm); + color: var(--sl-color-text-secondary); + line-height: normal; + } } - } - @media (min-width: 50rem) { - :global(:root[data-has-sidebar]) { - --__sidebar-pad: calc(2 * var(--sl-nav-pad-x)); - } - :global(:root:not([data-has-toc])) { - --__toc-width: 0rem; - } - .header { - --__sidebar-width: max(0rem, var(--sl-content-inline-start, 0rem) - var(--sl-nav-pad-x)); - --__main-column-fr: calc( - ( - 100% + var(--__sidebar-pad, 0rem) - var(--__toc-width, var(--sl-sidebar-width)) - - (2 * var(--__toc-width, var(--sl-nav-pad-x))) - var(--sl-content-inline-start, 0rem) - - var(--sl-content-width) - ) / 2 - ); - display: grid; - grid-template-columns: + @media (min-width: 50rem) { + :global(:root[data-has-sidebar]) { + --__sidebar-pad: calc(2 * var(--sl-nav-pad-x)); + } + + :global(:root:not([data-has-toc])) { + --__toc-width: 0rem; + } + + .header { + --__sidebar-width: max(0rem, var(--sl-content-inline-start, 0rem) - var(--sl-nav-pad-x)); + --__main-column-fr: calc( + ( + 100% + var(--__sidebar-pad, 0rem) - var(--__toc-width, var(--sl-sidebar-width)) - + (2 * var(--__toc-width, var(--sl-nav-pad-x))) - var(--sl-content-inline-start, 0rem) - + var(--sl-content-width) + ) / 2 + ); + display: grid; + grid-template-columns: /* 1 (site title): runs up until the main content column’s left edge or the width of the title, whichever is the largest */ - minmax( - calc(var(--__sidebar-width) + max(0rem, var(--__main-column-fr) - var(--sl-nav-gap))), - auto - ) - /* 2 (search box): all free space that is available. */ - 1fr - /* 3 (right items): use the space that these need. */ - auto; - align-content: center; - } - } + minmax(calc(var(--__sidebar-width) + max(0rem, var(--__main-column-fr) - var(--sl-nav-gap))), auto) + /* 2 (search box): all free space that is available. */ + 1fr + /* 3 (right items): use the space that these need. */ + auto; + align-content: center; + } + } </style> diff --git a/packages/web/src/components/Lander.astro b/packages/web/src/components/Lander.astro index 2bfe0a102..9713cfff6 100644 --- a/packages/web/src/components/Lander.astro +++ b/packages/web/src/components/Lander.astro @@ -1,7 +1,7 @@ --- import { Image } from 'astro:assets'; +import { getRelativeLocaleUrl } from 'astro:i18n'; 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"; @@ -19,8 +19,14 @@ const imageAttrs = { alt: image?.alt || '', }; -const github = config.social.filter(s => s.icon === 'github')[0]; -const discord = config.social.filter(s => s.icon === 'discord')[0]; +const github = (config.social || []).filter(s => s.icon === 'github')[0]; +const discord = (config.social || []).filter(s => s.icon === 'discord')[0]; +const locale = Astro.currentLocale || 'root'; +const t = Astro.locals.t as (key: string) => string; +const docsHref = getRelativeLocaleUrl(locale, "") +const docsCliHref = getRelativeLocaleUrl(locale, "cli") +const docsIdeHref = getRelativeLocaleUrl(locale, "ide") +const docsGithubHref = getRelativeLocaleUrl(locale, "github") const command = "curl -fsSL" const protocol = "https://" @@ -44,19 +50,21 @@ if (image) { <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" /> + {darkImage && ( + <Image + src={darkImage} + {...imageAttrs} + class:list={{ 'light:sl-hidden': Boolean(lightImage) }} + /> + )} + {lightImage && <Image src={lightImage} {...imageAttrs} class="dark:sl-hidden" />} </div> - <h1>The AI coding agent built for the terminal.</h1> + <h1>{t('app.lander.hero.title')}</h1> </section> <section class="cta"> <div class="col1"> - <a href="/docs">Get Started</a> + <a href={docsHref}>{t('app.lander.cta.getStarted')}</a> </div> <div class="col2"> <button class="command" data-command={`${command} ${protocol}${url} ${bash}`}> @@ -73,13 +81,13 @@ if (image) { <section class="content"> <ul> - <li><b>Native TUI</b>: A responsive, native, themeable terminal UI.</li> - <li><b>LSP enabled</b>: Automatically loads the right LSPs for the LLM.</li> - <li><b>Multi-session</b>: Start multiple agents in parallel on the same project.</li> - <li><b>Shareable links</b>: Share a link to any sessions for reference or to debug.</li> - <li><b>GitHub Copilot</b>: Log in with GitHub to use your Copilot account.</li> - <li><b>ChatGPT Plus/Pro</b>: Log in with OpenAI to use your ChatGPT Plus or Pro account.</li> - <li><b>Use any model</b>: Supports 75+ LLM providers through <a href="https://models.dev">Models.dev</a>, including local models.</li> + <li><b>{t('app.lander.features.native_tui.title')}</b>: {t('app.lander.features.native_tui.description')}</li> + <li><b>{t('app.lander.features.lsp_enabled.title')}</b>: {t('app.lander.features.lsp_enabled.description')}</li> + <li><b>{t('app.lander.features.multi_session.title')}</b>: {t('app.lander.features.multi_session.description')}</li> + <li><b>{t('app.lander.features.shareable_links.title')}</b>: {t('app.lander.features.shareable_links.description')}</li> + <li><b>GitHub Copilot</b>: {t('app.lander.features.github_copilot.description')}</li> + <li><b>ChatGPT Plus/Pro</b>: {t('app.lander.features.chatgpt_plus_pro.description')}</li> + <li><b>{t('app.lander.features.use_any_model.title')}</b>: {t('app.lander.features.use_any_model.prefix')} <a href="https://models.dev">Models.dev</a>, {t('app.lander.features.use_any_model.suffix')}</li> </ul> </section> @@ -149,26 +157,26 @@ if (image) { <section class="images"> <div class="left"> <figure> - <figcaption>opencode TUI with the tokyonight theme</figcaption> - <a href="/docs/cli"> - <Image src={TuiScreenshot} alt="opencode TUI with the tokyonight theme" /> + <figcaption>{t('app.lander.images.tui.caption')}</figcaption> + <a href={docsCliHref}> + <Image src={TuiScreenshot} alt={t('app.lander.images.tui.alt')} /> </a> </figure> </div> <div class="right"> <div class="row1"> <figure> - <figcaption>opencode in VS Code</figcaption> - <a href="/docs/ide"> - <Image src={VscodeScreenshot} alt="opencode in VS Code" /> + <figcaption>{t('app.lander.images.vscode.caption')}</figcaption> + <a href={docsIdeHref}> + <Image src={VscodeScreenshot} alt={t('app.lander.images.vscode.alt')} /> </a> </figure> </div> <div class="row2"> <figure> - <figcaption>opencode in GitHub</figcaption> - <a href="/docs/github"> - <Image src={GithubScreenshot} alt="opencode in GitHub" /> + <figcaption>{t('app.lander.images.github.caption')}</figcaption> + <a href={docsGithubHref}> + <Image src={GithubScreenshot} alt={t('app.lander.images.github.alt')} /> </a> </figure> </div> diff --git a/packages/web/src/components/Share.tsx b/packages/web/src/components/Share.tsx index 062449712..de27ccd53 100644 --- a/packages/web/src/components/Share.tsx +++ b/packages/web/src/components/Share.tsx @@ -1,8 +1,9 @@ -import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList, createEffect } from "solid-js" +import { For, Show, onMount, Suspense, onCleanup, createMemo, createSignal, SuspenseList } from "solid-js" import { DateTime } from "luxon" -import { createStore, reconcile, unwrap } from "solid-js/store" +import { createStore, reconcile } from "solid-js/store" import { IconArrowDown } from "./icons" import { IconOpencode } from "./icons/custom" +import { ShareI18nProvider, formatCurrency, formatNumber, normalizeLocale } from "./share/common" import styles from "./share.module.css" import type { MessageV2 } from "opencode/session/message-v2" import type { Message } from "opencode/session/message" @@ -20,24 +21,29 @@ function scrollToAnchor(id: string) { el.scrollIntoView({ behavior: "smooth" }) } -function getStatusText(status: [Status, string?]): string { +function getStatusText(status: [Status, string?], messages: Record<string, string>): string { switch (status[0]) { case "connected": - return "Connected, waiting for messages..." + return messages.status_connected_waiting case "connecting": - return "Connecting..." + return messages.status_connecting case "disconnected": - return "Disconnected" + return messages.status_disconnected case "reconnecting": - return "Reconnecting..." + return messages.status_reconnecting case "error": - return status[1] || "Error" + return status[1] || messages.status_error default: - return "Unknown" + return messages.status_unknown } } -export default function Share(props: { id: string; api: string; info: Session.Info }) { +export default function Share(props: { + id: string + api: string + info: Session.Info + messages: { locale: string } & Record<string, string> +}) { let lastScrollY = 0 let hasScrolledToAnchor = false let scrollTimeout: number | undefined @@ -57,6 +63,9 @@ export default function Share(props: { id: string; api: string; info: Session.In }>({ info: { id: props.id, + slug: props.info.slug, + projectID: props.info.projectID, + directory: props.info.directory, title: props.info.title, version: props.info.version, time: { @@ -67,22 +76,19 @@ export default function Share(props: { id: string; api: string; info: Session.In messages: {}, }) const messages = createMemo(() => Object.values(store.messages).toSorted((a, b) => a.id?.localeCompare(b.id))) - const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected", "Disconnected"]) - createEffect(() => { - console.log(unwrap(store)) - }) + const [connectionStatus, setConnectionStatus] = createSignal<[Status, string?]>(["disconnected"]) onMount(() => { const apiUrl = props.api if (!props.id) { - setConnectionStatus(["error", "id not found"]) + setConnectionStatus(["error", props.messages.error_id_not_found]) return } if (!apiUrl) { console.error("API URL not found in environment variables") - setConnectionStatus(["error", "API URL not found"]) + setConnectionStatus(["error", props.messages.error_api_url_not_found]) return } @@ -101,20 +107,16 @@ export default function Share(props: { id: string; api: string; info: Session.In // Always use secure WebSocket protocol (wss) const wsBaseUrl = apiUrl.replace(/^https?:\/\//, "wss://") const wsUrl = `${wsBaseUrl}/share_poll?id=${props.id}` - 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 d = JSON.parse(event.data) const [root, type, ...splits] = d.key.split("/") @@ -147,12 +149,11 @@ export default function Share(props: { id: string; api: string; info: Session.In // Handle errors socket.onerror = (error) => { console.error("WebSocket error:", error) - setConnectionStatus(["error", "Connection failed"]) + setConnectionStatus(["error", props.messages.error_connection_failed]) } // Handle connection close and reconnection - socket.onclose = (event) => { - console.log(`WebSocket closed: ${event.code} ${event.reason}`) + socket.onclose = () => { setConnectionStatus(["reconnecting"]) // Try to reconnect after 2 seconds @@ -166,7 +167,6 @@ export default function Share(props: { id: string; api: string; info: Session.In // Clean up on component unmount onCleanup(() => { - console.log("Cleaning up WebSocket connection") if (socket) { socket.close() } @@ -297,201 +297,212 @@ export default function Share(props: { id: string; api: string; info: Session.In return ( <Show when={store.info}> - <main classList={{ [styles.root]: true, "not-content": true }}> - <div data-component="header"> - <h1 data-component="header-title">{store.info?.title}</h1> - <div data-component="header-details"> - <ul data-component="header-stats"> - <li title="opencode version" data-slot="item"> - <div data-slot="icon" title="opencode"> - <IconOpencode width={16} height={16} /> - </div> - <Show when={store.info?.version} fallback="v0.0.1"> - <span>v{store.info?.version}</span> - </Show> - </li> - {Object.values(data().models).length > 0 ? ( - <For each={Object.values(data().models)}> - {([provider, model]) => ( - <li data-slot="item"> - <div data-slot="icon" title={provider}> - <ProviderIcon model={model} /> - </div> - <span data-slot="model">{model}</span> - </li> - )} - </For> - ) : ( - <li> - <span data-element-label>Models</span> - <span data-placeholder>—</span> + <ShareI18nProvider messages={props.messages}> + <main classList={{ [styles.root]: true, "not-content": true }}> + <div data-component="header"> + <h1 data-component="header-title">{store.info?.title}</h1> + <div data-component="header-details"> + <ul data-component="header-stats"> + <li title={props.messages.opencode_version} data-slot="item"> + <div data-slot="icon" title={props.messages.opencode_name}> + <IconOpencode width={16} height={16} /> + </div> + <Show when={store.info?.version} fallback="v0.0.1"> + <span>v{store.info?.version}</span> + </Show> </li> - )} - </ul> - <div - data-component="header-time" - title={DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)} - > - {DateTime.fromMillis(data().created || 0).toLocaleString(DateTime.DATETIME_MED)} + {Object.values(data().models).length > 0 ? ( + <For each={Object.values(data().models)}> + {([provider, model]) => ( + <li data-slot="item"> + <div data-slot="icon" title={provider}> + <ProviderIcon model={model} /> + </div> + <span data-slot="model">{model}</span> + </li> + )} + </For> + ) : ( + <li> + <span data-element-label>{props.messages.models}</span> + <span data-placeholder>—</span> + </li> + )} + </ul> + <div + data-component="header-time" + title={DateTime.fromMillis(data().created || 0) + .setLocale(normalizeLocale(props.messages.locale)) + .toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)} + > + {DateTime.fromMillis(data().created || 0) + .setLocale(normalizeLocale(props.messages.locale)) + .toLocaleString(DateTime.DATETIME_MED)} + </div> </div> </div> - </div> - - <div> - <Show when={data().messages.length > 0} fallback={<p>Waiting for messages...</p>}> - <div class={styles.parts}> - <SuspenseList revealOrder="forwards"> - <For each={data().messages}> - {(msg, msgIndex) => { - const filteredParts = createMemo(() => - msg.parts.filter((x, index) => { - if (x.type === "step-start" && index > 0) return false - if (x.type === "snapshot") return false - if (x.type === "patch") return false - if (x.type === "step-finish") return false - if (x.type === "text" && x.synthetic === true) return false - if (x.type === "tool" && x.tool === "todoread") return false - if (x.type === "text" && !x.text) return false - if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running")) - return false - return true - }), - ) - - return ( - <Suspense> - <For each={filteredParts()}> - {(part, partIndex) => { - const last = createMemo( - () => - data().messages.length === msgIndex() + 1 && filteredParts().length === partIndex() + 1, - ) - - onMount(() => { - const hash = window.location.hash.slice(1) - // Wait till all parts are loaded - if ( - hash !== "" && - !hasScrolledToAnchor && - filteredParts().length === partIndex() + 1 && - data().messages.length === msgIndex() + 1 - ) { - hasScrolledToAnchor = true - scrollToAnchor(hash) - } - }) - - return <Part last={last()} part={part} index={partIndex()} message={msg} /> - }} - </For> - </Suspense> - ) - }} - </For> - </SuspenseList> - <div data-section="part" data-part-type="summary"> - <div data-section="decoration"> - <span data-status={connectionStatus()[0]}></span> + + <div> + <Show when={data().messages.length > 0} fallback={<p>{props.messages.waiting_for_messages}</p>}> + <div class={styles.parts}> + <SuspenseList revealOrder="forwards"> + <For each={data().messages}> + {(msg, msgIndex) => { + const filteredParts = createMemo(() => + msg.parts.filter((x, index) => { + if (x.type === "step-start" && index > 0) return false + if (x.type === "snapshot") return false + if (x.type === "patch") return false + if (x.type === "step-finish") return false + if (x.type === "text" && x.synthetic === true) return false + if (x.type === "tool" && x.tool === "todoread") return false + if (x.type === "text" && !x.text) return false + if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running")) + return false + return true + }), + ) + + return ( + <Suspense> + <For each={filteredParts()}> + {(part, partIndex) => { + const last = createMemo( + () => + data().messages.length === msgIndex() + 1 && + filteredParts().length === partIndex() + 1, + ) + + onMount(() => { + const hash = window.location.hash.slice(1) + // Wait till all parts are loaded + if ( + hash !== "" && + !hasScrolledToAnchor && + filteredParts().length === partIndex() + 1 && + data().messages.length === msgIndex() + 1 + ) { + hasScrolledToAnchor = true + scrollToAnchor(hash) + } + }) + + return <Part last={last()} part={part} index={partIndex()} message={msg} /> + }} + </For> + </Suspense> + ) + }} + </For> + </SuspenseList> + <div data-section="part" data-part-type="summary"> + <div data-section="decoration"> + <span data-status={connectionStatus()[0]}></span> + </div> + <div data-section="content"> + <p data-section="copy">{getStatusText(connectionStatus(), props.messages)}</p> + <ul data-section="stats"> + <li> + <span data-element-label>{props.messages.cost}</span> + {data().cost !== undefined ? ( + <span>{formatCurrency(data().cost, props.messages.locale)}</span> + ) : ( + <span data-placeholder>—</span> + )} + </li> + <li> + <span data-element-label>{props.messages.input_tokens}</span> + {data().tokens.input ? ( + <span>{formatNumber(data().tokens.input, props.messages.locale)}</span> + ) : ( + <span data-placeholder>—</span> + )} + </li> + <li> + <span data-element-label>{props.messages.output_tokens}</span> + {data().tokens.output ? ( + <span>{formatNumber(data().tokens.output, props.messages.locale)}</span> + ) : ( + <span data-placeholder>—</span> + )} + </li> + <li> + <span data-element-label>{props.messages.reasoning_tokens}</span> + {data().tokens.reasoning ? ( + <span>{formatNumber(data().tokens.reasoning, props.messages.locale)}</span> + ) : ( + <span data-placeholder>—</span> + )} + </li> + </ul> + </div> </div> - <div data-section="content"> - <p data-section="copy">{getStatusText(connectionStatus())}</p> - <ul data-section="stats"> - <li> - <span data-element-label>Cost</span> - {data().cost !== undefined ? ( - <span>${data().cost.toFixed(2)}</span> - ) : ( - <span data-placeholder>—</span> - )} - </li> - <li> - <span data-element-label>Input Tokens</span> - {data().tokens.input ? <span>{data().tokens.input}</span> : <span data-placeholder>—</span>} - </li> - <li> - <span data-element-label>Output Tokens</span> - {data().tokens.output ? ( - <span>{data().tokens.output}</span> - ) : ( - <span data-placeholder>—</span> - )} - </li> - <li> - <span data-element-label>Reasoning Tokens</span> - {data().tokens.reasoning ? ( - <span>{data().tokens.reasoning}</span> - ) : ( - <span data-placeholder>—</span> + </div> + </Show> + </div> + + <Show when={debug}> + <div style={{ margin: "2rem 0" }}> + <div + style={{ + border: "1px solid #ccc", + padding: "1rem", + "overflow-y": "auto", + }} + > + <Show when={data().messages.length > 0} fallback={<p>{props.messages.waiting_for_messages}</p>}> + <ul style={{ "list-style-type": "none", padding: 0 }}> + <For each={data().messages}> + {(msg) => ( + <li + style={{ + padding: "0.75rem", + margin: "0.75rem 0", + "box-shadow": "0 1px 3px rgba(0,0,0,0.1)", + }} + > + <div> + <strong>{props.messages.debug_key}:</strong> {msg.id} + </div> + <pre>{JSON.stringify(msg, null, 2)}</pre> + </li> )} - </li> + </For> </ul> - </div> + </Show> </div> </div> </Show> - </div> - - <Show when={debug}> - <div style={{ margin: "2rem 0" }}> - <div - style={{ - border: "1px solid #ccc", - padding: "1rem", - "overflow-y": "auto", + + <Show when={showScrollButton()}> + <button + type="button" + class={styles["scroll-button"]} + onClick={() => document.body.scrollIntoView({ behavior: "smooth", block: "end" })} + onMouseEnter={() => { + setIsButtonHovered(true) + if (scrollTimeout) { + clearTimeout(scrollTimeout) + } + }} + onMouseLeave={() => { + setIsButtonHovered(false) + if (showScrollButton()) { + scrollTimeout = window.setTimeout(() => { + if (!isButtonHovered()) { + setShowScrollButton(false) + } + }, 3000) + } }} + title={props.messages.scroll_to_bottom} + aria-label={props.messages.scroll_to_bottom} > - <Show when={data().messages.length > 0} fallback={<p>Waiting for messages...</p>}> - <ul style={{ "list-style-type": "none", padding: 0 }}> - <For each={data().messages}> - {(msg) => ( - <li - style={{ - padding: "0.75rem", - margin: "0.75rem 0", - "box-shadow": "0 1px 3px rgba(0,0,0,0.1)", - }} - > - <div> - <strong>Key:</strong> {msg.id} - </div> - <pre>{JSON.stringify(msg, null, 2)}</pre> - </li> - )} - </For> - </ul> - </Show> - </div> - </div> - </Show> - - <Show when={showScrollButton()}> - <button - type="button" - class={styles["scroll-button"]} - onClick={() => document.body.scrollIntoView({ behavior: "smooth", block: "end" })} - onMouseEnter={() => { - setIsButtonHovered(true) - if (scrollTimeout) { - clearTimeout(scrollTimeout) - } - }} - onMouseLeave={() => { - setIsButtonHovered(false) - if (showScrollButton()) { - scrollTimeout = window.setTimeout(() => { - if (!isButtonHovered()) { - setShowScrollButton(false) - } - }, 3000) - } - }} - title="Scroll to bottom" - aria-label="Scroll to bottom" - > - <IconArrowDown width={20} height={20} /> - </button> - </Show> - </main> + <IconArrowDown width={20} height={20} /> + </button> + </Show> + </main> + </ShareI18nProvider> </Show> ) } @@ -502,6 +513,8 @@ export function fromV1(v1: Message.Info): MessageWithParts { id: v1.id, sessionID: v1.metadata.sessionID, role: "assistant", + parentID: "", + agent: "build", time: { created: v1.metadata.time.created, completed: v1.metadata.time.completed, @@ -521,7 +534,6 @@ export function fromV1(v1: Message.Info): MessageWithParts { modelID: v1.metadata.assistant!.modelID, providerID: v1.metadata.assistant!.providerID, mode: "build", - system: v1.metadata.assistant!.system, error: v1.metadata.error, parts: v1.parts.flatMap((part, index): MessageV2.Part[] => { const base = { @@ -557,6 +569,8 @@ export function fromV1(v1: Message.Info): MessageWithParts { if (part.toolInvocation.state === "partial-call") { return { status: "pending", + input: {}, + raw: "", } } @@ -596,6 +610,11 @@ export function fromV1(v1: Message.Info): MessageWithParts { id: v1.id, sessionID: v1.metadata.sessionID, role: "user", + agent: "user", + model: { + providerID: "", + modelID: "", + }, time: { created: v1.metadata.time.created, }, diff --git a/packages/web/src/components/SiteTitle.astro b/packages/web/src/components/SiteTitle.astro index 28a30cb23..6f1405cb1 100644 --- a/packages/web/src/components/SiteTitle.astro +++ b/packages/web/src/components/SiteTitle.astro @@ -4,7 +4,7 @@ import config from 'virtual:starlight/user-config'; const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute; --- -<a href="/" class="site-title sl-flex"> +<a href={siteTitleHref} class="site-title sl-flex"> { config.logo && logos.dark && ( <> diff --git a/packages/web/src/components/share/common.tsx b/packages/web/src/components/share/common.tsx index cab2dbdb0..7ca4daa6a 100644 --- a/packages/web/src/components/share/common.tsx +++ b/packages/web/src/components/share/common.tsx @@ -1,16 +1,55 @@ -import { createSignal, onCleanup, splitProps } from "solid-js" +import { createContext, createSignal, onCleanup, splitProps, useContext } from "solid-js" import type { JSX } from "solid-js/jsx-runtime" import { IconCheckCircle, IconHashtag } from "../icons" +export type ShareMessages = { locale: string } & Record<string, string> + +const shareContext = createContext<ShareMessages>() + +export function ShareI18nProvider(props: { messages: ShareMessages; children: JSX.Element }) { + return <shareContext.Provider value={props.messages}>{props.children}</shareContext.Provider> +} + +export function useShareMessages() { + const value = useContext(shareContext) + if (value) { + return value + } + throw new Error("ShareI18nProvider is required") +} + +export function normalizeLocale(locale: string) { + return locale === "root" ? "en" : locale +} + +export function formatNumber(value: number, locale: string) { + return new Intl.NumberFormat(normalizeLocale(locale)).format(value) +} + +export function formatCurrency(value: number, locale: string) { + return new Intl.NumberFormat(normalizeLocale(locale), { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value) +} + +export function formatCount(value: number, locale: string, singular: string, plural: string) { + const unit = value === 1 ? singular : plural + return `${formatNumber(value, locale)} ${unit}` +} + interface AnchorProps extends JSX.HTMLAttributes<HTMLDivElement> { id: string } export function AnchorIcon(props: AnchorProps) { const [local, rest] = splitProps(props, ["id", "children"]) const [copied, setCopied] = createSignal(false) + const messages = useShareMessages() return ( - <div {...rest} data-element-anchor title="Link to this message" data-status={copied() ? "copied" : ""}> + <div {...rest} data-element-anchor title={messages.link_to_message} data-status={copied() ? "copied" : ""}> <a href={`#${local.id}`} onClick={(e) => { @@ -32,7 +71,7 @@ export function AnchorIcon(props: AnchorProps) { <IconHashtag width={18} height={18} /> <IconCheckCircle width={18} height={18} /> </a> - <span data-element-tooltip>Copied!</span> + <span data-element-tooltip>{messages.copied}</span> </div> ) } @@ -59,19 +98,33 @@ export function createOverflow() { } } -export function formatDuration(ms: number): string { +export function formatDuration(ms: number, locale: string): string { + const normalized = normalizeLocale(locale) const ONE_SECOND = 1000 const ONE_MINUTE = 60 * ONE_SECOND if (ms >= ONE_MINUTE) { - const minutes = Math.floor(ms / ONE_MINUTE) - return minutes === 1 ? `1min` : `${minutes}mins` + return new Intl.NumberFormat(normalized, { + style: "unit", + unit: "minute", + unitDisplay: "narrow", + maximumFractionDigits: 0, + }).format(Math.floor(ms / ONE_MINUTE)) } if (ms >= ONE_SECOND) { - const seconds = Math.floor(ms / ONE_SECOND) - return `${seconds}s` + return new Intl.NumberFormat(normalized, { + style: "unit", + unit: "second", + unitDisplay: "narrow", + maximumFractionDigits: 0, + }).format(Math.floor(ms / ONE_SECOND)) } - return `${ms}ms` + return new Intl.NumberFormat(normalized, { + style: "unit", + unit: "millisecond", + unitDisplay: "narrow", + maximumFractionDigits: 0, + }).format(ms) } diff --git a/packages/web/src/components/share/content-bash.tsx b/packages/web/src/components/share/content-bash.tsx index 5ccd95c0b..f8130ecc6 100644 --- a/packages/web/src/components/share/content-bash.tsx +++ b/packages/web/src/components/share/content-bash.tsx @@ -1,6 +1,6 @@ import style from "./content-bash.module.css" import { createResource, createSignal } from "solid-js" -import { createOverflow } from "./common" +import { createOverflow, useShareMessages } from "./common" import { codeToHtml } from "shiki" interface Props { @@ -11,6 +11,7 @@ interface Props { } export function ContentBash(props: Props) { + const messages = useShareMessages() const [commandHtml] = createResource( () => props.command, async (command) => { @@ -59,7 +60,7 @@ export function ContentBash(props: Props) { data-slot="expand-button" onClick={() => setExpanded((e) => !e)} > - {expanded() ? "Show less" : "Show more"} + {expanded() ? messages.show_less : messages.show_more} </button> )} </div> diff --git a/packages/web/src/components/share/content-code.tsx b/packages/web/src/components/share/content-code.tsx index 2f383b8be..297828602 100644 --- a/packages/web/src/components/share/content-code.tsx +++ b/packages/web/src/components/share/content-code.tsx @@ -1,6 +1,5 @@ import { codeToHtml, bundledLanguages } from "shiki" import { createResource, Suspense } from "solid-js" -import { transformerNotationDiff } from "@shikijs/transformers" import style from "./content-code.module.css" interface Props { @@ -20,7 +19,6 @@ export function ContentCode(props: Props) { light: "github-light", dark: "github-dark", }, - transformers: [transformerNotationDiff()], })) as string }, ) diff --git a/packages/web/src/components/share/content-error.tsx b/packages/web/src/components/share/content-error.tsx index 1e8cbeaad..0f8588997 100644 --- a/packages/web/src/components/share/content-error.tsx +++ b/packages/web/src/components/share/content-error.tsx @@ -1,6 +1,6 @@ import style from "./content-error.module.css" import { type JSX, createSignal } from "solid-js" -import { createOverflow } from "./common" +import { createOverflow, useShareMessages } from "./common" interface Props extends JSX.HTMLAttributes<HTMLDivElement> { expand?: boolean @@ -8,6 +8,7 @@ interface Props extends JSX.HTMLAttributes<HTMLDivElement> { export function ContentError(props: Props) { const [expanded, setExpanded] = createSignal(false) const overflow = createOverflow() + const messages = useShareMessages() return ( <div class={style.root} data-expanded={expanded() || props.expand === true ? true : undefined}> @@ -16,7 +17,7 @@ export function ContentError(props: Props) { </div> {((!props.expand && overflow.status) || expanded()) && ( <button type="button" data-element-button-text onClick={() => setExpanded((e) => !e)}> - {expanded() ? "Show less" : "Show more"} + {expanded() ? messages.show_less : messages.show_more} </button> )} </div> diff --git a/packages/web/src/components/share/content-markdown.tsx b/packages/web/src/components/share/content-markdown.tsx index b9b1d5dcb..10a06bf5e 100644 --- a/packages/web/src/components/share/content-markdown.tsx +++ b/packages/web/src/components/share/content-markdown.tsx @@ -1,10 +1,9 @@ import { marked } from "marked" import { codeToHtml } from "shiki" import markedShiki from "marked-shiki" -import { createOverflow } from "./common" +import { createOverflow, useShareMessages } from "./common" import { CopyButton } from "./copy-button" import { createResource, createSignal } from "solid-js" -import { transformerNotationDiff } from "@shikijs/transformers" import style from "./content-markdown.module.css" const markedWithShiki = marked.use( @@ -24,7 +23,6 @@ const markedWithShiki = marked.use( light: "github-light", dark: "github-dark", }, - transformers: [transformerNotationDiff()], }) }, }), @@ -44,6 +42,7 @@ export function ContentMarkdown(props: Props) { ) const [expanded, setExpanded] = createSignal(false) const overflow = createOverflow() + const messages = useShareMessages() return ( <div @@ -60,7 +59,7 @@ export function ContentMarkdown(props: Props) { data-slot="expand-button" onClick={() => setExpanded((e) => !e)} > - {expanded() ? "Show less" : "Show more"} + {expanded() ? messages.show_less : messages.show_more} </button> )} <CopyButton text={props.text} /> diff --git a/packages/web/src/components/share/content-text.tsx b/packages/web/src/components/share/content-text.tsx index 5db12a537..b549b74a4 100644 --- a/packages/web/src/components/share/content-text.tsx +++ b/packages/web/src/components/share/content-text.tsx @@ -1,6 +1,6 @@ import style from "./content-text.module.css" import { createSignal } from "solid-js" -import { createOverflow } from "./common" +import { createOverflow, useShareMessages } from "./common" import { CopyButton } from "./copy-button" interface Props { @@ -11,6 +11,7 @@ interface Props { export function ContentText(props: Props) { const [expanded, setExpanded] = createSignal(false) const overflow = createOverflow() + const messages = useShareMessages() return ( <div @@ -28,7 +29,7 @@ export function ContentText(props: Props) { data-slot="expand-button" onClick={() => setExpanded((e) => !e)} > - {expanded() ? "Show less" : "Show more"} + {expanded() ? messages.show_less : messages.show_more} </button> )} <CopyButton text={props.text} /> diff --git a/packages/web/src/components/share/copy-button.tsx b/packages/web/src/components/share/copy-button.tsx index 892d5553f..6c5097a99 100644 --- a/packages/web/src/components/share/copy-button.tsx +++ b/packages/web/src/components/share/copy-button.tsx @@ -1,5 +1,6 @@ import { createSignal } from "solid-js" import { IconClipboard, IconCheckCircle } from "../icons" +import { useShareMessages } from "./common" import styles from "./copy-button.module.css" interface CopyButtonProps { @@ -8,6 +9,7 @@ interface CopyButtonProps { export function CopyButton(props: CopyButtonProps) { const [copied, setCopied] = createSignal(false) + const messages = useShareMessages() function handleCopyClick() { if (props.text) { @@ -20,7 +22,13 @@ export function CopyButton(props: CopyButtonProps) { return ( <div data-component="copy-button" class={styles.root}> - <button type="button" onClick={handleCopyClick} data-copied={copied() ? true : undefined}> + <button + type="button" + onClick={handleCopyClick} + data-copied={copied() ? true : undefined} + aria-label={copied() ? messages.copied : messages.copy} + title={copied() ? messages.copied : messages.copy} + > {copied() ? <IconCheckCircle width={16} height={16} /> : <IconClipboard width={16} height={16} />} </button> </div> diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index f7a6a9304..45bd97fe3 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -25,7 +25,7 @@ import { ContentDiff } from "./content-diff" import { ContentText } from "./content-text" import { ContentBash } from "./content-bash" import { ContentError } from "./content-error" -import { formatDuration } from "../share/common" +import { formatCount, formatDuration, formatNumber, normalizeLocale, useShareMessages } from "../share/common" import { ContentMarkdown } from "./content-markdown" import type { MessageV2 } from "opencode/session/message-v2" import type { Diagnostic } from "vscode-languageserver-types" @@ -44,6 +44,7 @@ export interface PartProps { export function Part(props: PartProps) { const [copied, setCopied] = createSignal(false) const id = createMemo(() => props.message.id + "-" + props.index) + const messages = useShareMessages() return ( <div @@ -55,7 +56,7 @@ export function Part(props: PartProps) { data-copied={copied() ? true : undefined} > <div data-component="decoration"> - <div data-slot="anchor" title="Link to this message"> + <div data-slot="anchor" title={messages.link_to_message}> <a href={`#${id()}`} onClick={(e) => { @@ -126,7 +127,7 @@ export function Part(props: PartProps) { <IconHashtag width={18} height={18} /> <IconCheckCircle width={18} height={18} /> </a> - <span data-slot="tooltip">Copied!</span> + <span data-slot="tooltip">{messages.copied}</span> </div> <div data-slot="bar"></div> </div> @@ -143,11 +144,13 @@ export function Part(props: PartProps) { </div> {props.last && props.message.role === "assistant" && props.message.time.completed && ( <Footer - title={DateTime.fromMillis(props.message.time.completed).toLocaleString( - DateTime.DATETIME_FULL_WITH_SECONDS, - )} + title={DateTime.fromMillis(props.message.time.completed) + .setLocale(normalizeLocale(messages.locale)) + .toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)} > - {DateTime.fromMillis(props.message.time.completed).toLocaleString(DateTime.DATETIME_MED)} + {DateTime.fromMillis(props.message.time.completed) + .setLocale(normalizeLocale(messages.locale)) + .toLocaleString(DateTime.DATETIME_MED)} </Footer> )} </div> @@ -155,13 +158,13 @@ export function Part(props: PartProps) { {props.message.role === "assistant" && props.part.type === "reasoning" && ( <div data-component="tool"> <div data-component="tool-title"> - <span data-slot="name">Thinking</span> + <span data-slot="name">{messages.thinking}</span> </div> <Show when={props.part.text}> <div data-component="assistant-reasoning"> - <ResultsButton showCopy="Show details" hideCopy="Hide details"> + <ResultsButton showCopy={messages.show_details} hideCopy={messages.hide_details}> <div data-component="assistant-reasoning-markdown"> - <ContentMarkdown expand text={props.part.text || "Thinking..."} /> + <ContentMarkdown expand text={props.part.text || messages.thinking_pending} /> </div> </ResultsButton> </div> @@ -170,13 +173,7 @@ export function Part(props: PartProps) { )} {props.message.role === "user" && props.part.type === "file" && ( <div data-component="attachment"> - <div data-slot="copy">Attachment</div> - <div data-slot="filename">{props.part.filename}</div> - </div> - )} - {props.message.role === "user" && props.part.type === "file" && ( - <div data-component="attachment"> - <div data-slot="copy">Attachment</div> + <div data-slot="copy">{messages.attachment}</div> <div data-slot="filename">{props.part.filename}</div> </div> )} @@ -188,7 +185,7 @@ export function Part(props: PartProps) { )} {props.part.type === "tool" && props.part.state.status === "error" && ( <div data-component="tool" data-tool="error"> - <ContentError>{formatErrorString(props.part.state.error)}</ContentError> + <ContentError>{formatErrorString(props.part.state.error, messages.error)}</ContentError> <Spacer /> </div> )} @@ -343,43 +340,45 @@ function getShikiLang(filename: string) { return type ? (overrides[type] ?? type) : "plaintext" } -function getDiagnostics(diagnosticsByFile: Record<string, Diagnostic[]>, currentFile: string): JSX.Element[] { +function getDiagnostics( + diagnosticsByFile: Record<string, Diagnostic[]>, + currentFile: string, + label: string, +): JSX.Element[] { const result: JSX.Element[] = [] if (diagnosticsByFile === undefined || diagnosticsByFile[currentFile] === undefined) return result - for (const diags of Object.values(diagnosticsByFile)) { - for (const d of diags) { - if (d.severity !== 1) continue - - const line = d.range.start.line + 1 - const column = d.range.start.character + 1 - - result.push( - <pre> - <span data-color="red" data-marker="label"> - Error - </span> - <span data-color="dimmed" data-separator> - [{line}:{column}] - </span> - <span>{d.message}</span> - </pre>, - ) - } + for (const d of diagnosticsByFile[currentFile]) { + if (d.severity !== 1) continue + + const line = d.range.start.line + 1 + const column = d.range.start.character + 1 + + result.push( + <pre> + <span data-color="red" data-marker="label"> + {label} + </span> + <span data-color="dimmed" data-separator> + [{line}:{column}] + </span> + <span>{d.message}</span> + </pre>, + ) } return result } -function formatErrorString(error: string): JSX.Element { +function formatErrorString(error: string, label: string): JSX.Element { const errorMarker = "Error: " const startsWithError = error.startsWith(errorMarker) return startsWithError ? ( <pre> <span data-color="red" data-marker="label" data-separator> - Error + {label} </span> <span>{error.slice(errorMarker.length)}</span> </pre> @@ -391,6 +390,7 @@ function formatErrorString(error: string): JSX.Element { } export function TodoWriteTool(props: ToolProps) { + const messages = useShareMessages() const priority: Record<Todo["status"], number> = { in_progress: 0, pending: 1, @@ -406,9 +406,9 @@ export function TodoWriteTool(props: ToolProps) { <> <div data-component="tool-title"> <span data-slot="name"> - <Switch fallback="Updating plan"> - <Match when={starting()}>Creating plan</Match> - <Match when={finished()}>Completing plan</Match> + <Switch fallback={messages.updating_plan}> + <Match when={starting()}>{messages.creating_plan}</Match> + <Match when={finished()}>{messages.completing_plan}</Match> </Switch> </span> </div> @@ -429,6 +429,8 @@ export function TodoWriteTool(props: ToolProps) { } export function GrepTool(props: ToolProps) { + const messages = useShareMessages() + return ( <> <div data-component="tool-title"> @@ -439,7 +441,12 @@ export function GrepTool(props: ToolProps) { <Switch> <Match when={props.state.metadata?.matches && props.state.metadata?.matches > 0}> <ResultsButton - showCopy={props.state.metadata?.matches === 1 ? "1 match" : `${props.state.metadata?.matches} matches`} + showCopy={formatCount( + props.state.metadata?.matches || 0, + messages.locale, + messages.match_one, + messages.match_other, + )} > <ContentText expand compact text={props.state.output} /> </ResultsButton> @@ -482,6 +489,8 @@ export function ListTool(props: ToolProps) { } export function WebFetchTool(props: ToolProps) { + const messages = useShareMessages() + return ( <> <div data-component="tool-title"> @@ -491,7 +500,7 @@ export function WebFetchTool(props: ToolProps) { <div data-component="tool-result"> <Switch> <Match when={props.state.metadata?.error}> - <ContentError>{formatErrorString(props.state.output)}</ContentError> + <ContentError>{formatErrorString(props.state.output, messages.error)}</ContentError> </Match> <Match when={props.state.output}> <ResultsButton> @@ -505,6 +514,7 @@ export function WebFetchTool(props: ToolProps) { } export function ReadTool(props: ToolProps) { + const messages = useShareMessages() const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) return ( @@ -518,10 +528,10 @@ export function ReadTool(props: ToolProps) { <div data-component="tool-result"> <Switch> <Match when={props.state.metadata?.error}> - <ContentError>{formatErrorString(props.state.output)}</ContentError> + <ContentError>{formatErrorString(props.state.output, messages.error)}</ContentError> </Match> <Match when={typeof props.state.metadata?.preview === "string"}> - <ResultsButton showCopy="Show preview" hideCopy="Hide preview"> + <ResultsButton showCopy={messages.show_preview} hideCopy={messages.hide_preview}> <ContentCode lang={getShikiLang(filePath() || "")} code={props.state.metadata?.preview} /> </ResultsButton> </Match> @@ -537,8 +547,11 @@ export function ReadTool(props: ToolProps) { } export function WriteTool(props: ToolProps) { + const messages = useShareMessages() const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) - const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + const diagnostics = createMemo(() => + getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath, messages.error), + ) return ( <> @@ -554,10 +567,10 @@ export function WriteTool(props: ToolProps) { <div data-component="tool-result"> <Switch> <Match when={props.state.metadata?.error}> - <ContentError>{formatErrorString(props.state.output)}</ContentError> + <ContentError>{formatErrorString(props.state.output, messages.error)}</ContentError> </Match> <Match when={props.state.input?.content}> - <ResultsButton showCopy="Show contents" hideCopy="Hide contents"> + <ResultsButton showCopy={messages.show_contents} hideCopy={messages.hide_contents}> <ContentCode lang={getShikiLang(filePath() || "")} code={props.state.input?.content} /> </ResultsButton> </Match> @@ -568,8 +581,11 @@ export function WriteTool(props: ToolProps) { } export function EditTool(props: ToolProps) { + const messages = useShareMessages() const filePath = createMemo(() => stripWorkingDirectory(props.state.input.filePath, props.message.path.cwd)) - const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + const diagnostics = createMemo(() => + getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath, messages.error), + ) return ( <> @@ -582,7 +598,7 @@ export function EditTool(props: ToolProps) { <div data-component="tool-result"> <Switch> <Match when={props.state.metadata?.error}> - <ContentError>{formatErrorString(props.state.metadata?.message || "")}</ContentError> + <ContentError>{formatErrorString(props.state.metadata?.message || "", messages.error)}</ContentError> </Match> <Match when={props.state.metadata?.diff}> <div data-component="diff"> @@ -609,6 +625,8 @@ export function BashTool(props: ToolProps) { } export function GlobTool(props: ToolProps) { + const messages = useShareMessages() + return ( <> <div data-component="tool-title"> @@ -619,7 +637,12 @@ export function GlobTool(props: ToolProps) { <Match when={props.state.metadata?.count && props.state.metadata?.count > 0}> <div data-component="tool-result"> <ResultsButton - showCopy={props.state.metadata?.count === 1 ? "1 result" : `${props.state.metadata?.count} results`} + showCopy={formatCount( + props.state.metadata?.count || 0, + messages.locale, + messages.result_one, + messages.result_other, + )} > <ContentText expand compact text={props.state.output} /> </ResultsButton> @@ -639,11 +662,12 @@ interface ResultsButtonProps extends ParentProps { } function ResultsButton(props: ResultsButtonProps) { const [show, setShow] = createSignal(false) + const messages = useShareMessages() return ( <> <button type="button" data-component="button-text" data-more onClick={() => setShow((e) => !e)}> - <span>{show() ? props.hideCopy || "Hide results" : props.showCopy || "Show results"}</span> + <span>{show() ? props.hideCopy || messages.hide_results : props.showCopy || messages.show_results}</span> <span data-slot="icon"> <Show when={show()} fallback={<IconChevronRight width={11} height={11} />}> <IconChevronDown width={11} height={11} /> @@ -668,10 +692,19 @@ function Footer(props: ParentProps<{ title: string }>) { } function ToolFooter(props: { time: number }) { - return props.time > MIN_DURATION && <Footer title={`${props.time}ms`}>{formatDuration(props.time)}</Footer> + const messages = useShareMessages() + return ( + props.time > MIN_DURATION && ( + <Footer title={`${formatNumber(props.time, messages.locale)}ms`}> + {formatDuration(props.time, messages.locale)} + </Footer> + ) + ) } function TaskTool(props: ToolProps) { + const messages = useShareMessages() + return ( <> <div data-component="tool-title"> @@ -679,7 +712,7 @@ function TaskTool(props: ToolProps) { <span data-slot="target">{props.state.input.description}</span> </div> <div data-component="tool-input">“{props.state.input.prompt}”</div> - <ResultsButton showCopy="Show output" hideCopy="Hide output"> + <ResultsButton showCopy={messages.show_output} hideCopy={messages.hide_output}> <div data-component="tool-output"> <ContentMarkdown expand text={props.state.output} /> </div> @@ -700,7 +733,7 @@ export function FallbackTool(props: ToolProps) { <> <div></div> <div>{arg[0]}</div> - <div>{arg[1]}</div> + <div>{String(arg[1] ?? "")}</div> </> )} </For> @@ -720,10 +753,11 @@ export function FallbackTool(props: ToolProps) { // Converts nested objects/arrays into [path, value] pairs. // E.g. {a:{b:{c:1}}, d:[{e:2}, 3]} => [["a.b.c",1], ["d[0].e",2], ["d[1]",3]] -function flattenToolArgs(obj: any, prefix: string = ""): Array<[string, any]> { - const entries: Array<[string, any]> = [] +function flattenToolArgs(obj: unknown, prefix: string = ""): Array<[string, unknown]> { + const entries: Array<[string, unknown]> = [] + if (typeof obj !== "object" || obj === null) return entries - for (const [key, value] of Object.entries(obj)) { + for (const [key, value] of Object.entries(obj as Record<string, unknown>)) { const path = prefix ? `${prefix}.${key}` : key if (value !== null && typeof value === "object") { |
