wip(docs): i18n (#12681)
This commit is contained in:
125
packages/web/src/components/Footer.astro
Normal file
125
packages/web/src/components/Footer.astro
Normal file
@@ -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>
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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 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)
|
||||
|
||||
const links = astroConfig.social || [];
|
||||
const headerLinks = config.headerLinks;
|
||||
|
||||
---
|
||||
|
||||
{ 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>
|
||||
function href(url: string) {
|
||||
if (url === "/" || url === "/docs" || url === "/docs/") {
|
||||
return getRelativeLocaleUrl(locale, "")
|
||||
}
|
||||
return url
|
||||
}
|
||||
---
|
||||
|
||||
{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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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:
|
||||
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:
|
||||
/* 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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={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}
|
||||
{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>
|
||||
<pre>{JSON.stringify(msg, null, 2)}</pre>
|
||||
<span data-slot="model">{model}</span>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
) : (
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</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={props.messages.scroll_to_bottom}
|
||||
aria-label={props.messages.scroll_to_bottom}
|
||||
>
|
||||
<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,
|
||||
},
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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>,
|
||||
)
|
||||
}
|
||||
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") {
|
||||
|
||||
Reference in New Issue
Block a user