diff options
| author | Dax <[email protected]> | 2025-07-07 15:53:43 -0400 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-07-07 15:53:43 -0400 |
| commit | f884766445bbf1fbce11f1db4bc6174e72d9baa5 (patch) | |
| tree | e7d9e7b3d8efc8bed249f9d29e2d8fb838a275f2 /packages/web/src/components/share | |
| parent | 76b2e4539cb97bae5812ed2d832ce49d02e70c64 (diff) | |
| download | opencode-f884766445bbf1fbce11f1db4bc6174e72d9baa5.tar.gz opencode-f884766445bbf1fbce11f1db4bc6174e72d9baa5.zip | |
v2 message format and upgrade to ai sdk v5 (#743)
Co-authored-by: GitHub Action <[email protected]>
Co-authored-by: Liang-Shih Lin <[email protected]>
Co-authored-by: Dominik Engelhardt <[email protected]>
Co-authored-by: Jay V <[email protected]>
Co-authored-by: adamdottv <[email protected]>
Diffstat (limited to 'packages/web/src/components/share')
| -rw-r--r-- | packages/web/src/components/share/common.tsx | 60 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-code.module.css | 25 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-code.tsx | 32 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-diff.module.css | 125 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-diff.tsx | 231 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-markdown.module.css | 140 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-markdown.tsx | 65 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-text.module.css | 57 | ||||
| -rw-r--r-- | packages/web/src/components/share/content-text.tsx | 35 | ||||
| -rw-r--r-- | packages/web/src/components/share/part.module.css | 375 | ||||
| -rw-r--r-- | packages/web/src/components/share/part.tsx | 664 |
11 files changed, 1809 insertions, 0 deletions
diff --git a/packages/web/src/components/share/common.tsx b/packages/web/src/components/share/common.tsx new file mode 100644 index 000000000..9f5221de9 --- /dev/null +++ b/packages/web/src/components/share/common.tsx @@ -0,0 +1,60 @@ +import { createSignal, onCleanup, splitProps } from "solid-js" +import type { JSX } from "solid-js/jsx-runtime" +import { IconCheckCircle, IconHashtag } from "../icons" + +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) + + return ( + <div {...rest} data-element-anchor title="Link to this message" data-status={copied() ? "copied" : ""}> + <a + href={`#${local.id}`} + onClick={(e) => { + e.preventDefault() + + const anchor = e.currentTarget + const hash = anchor.getAttribute("href") || "" + const { origin, pathname, search } = window.location + + navigator.clipboard + .writeText(`${origin}${pathname}${search}${hash}`) + .catch((err) => console.error("Copy failed", err)) + + setCopied(true) + setTimeout(() => setCopied(false), 3000) + }} + > + {local.children} + <IconHashtag width={18} height={18} /> + <IconCheckCircle width={18} height={18} /> + </a> + <span data-element-tooltip>Copied!</span> + </div> + ) +} + +export function createOverflow() { + const [overflow, setOverflow] = createSignal(false) + return { + get status() { + return overflow() + }, + ref(el: HTMLElement) { + const ro = new ResizeObserver(() => { + if (el.scrollHeight > el.clientHeight + 1) { + setOverflow(true) + } + return + }) + ro.observe(el) + + onCleanup(() => { + ro.disconnect() + }) + }, + } +} diff --git a/packages/web/src/components/share/content-code.module.css b/packages/web/src/components/share/content-code.module.css new file mode 100644 index 000000000..b95f936da --- /dev/null +++ b/packages/web/src/components/share/content-code.module.css @@ -0,0 +1,25 @@ +.root { + max-width: var(--md-tool-width); + border: 1px solid var(--sl-color-divider); + background-color: var(--sl-color-bg-surface); + border-radius: 0.25rem; + padding: 0.5rem calc(0.5rem + 3px); + + &[data-flush="true"] { + border: none; + background-color: transparent; + padding: 0; + } + + pre { + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + line-height: 1.6; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + + span { + white-space: break-spaces; + } + } +} diff --git a/packages/web/src/components/share/content-code.tsx b/packages/web/src/components/share/content-code.tsx new file mode 100644 index 000000000..b8c4f2ccd --- /dev/null +++ b/packages/web/src/components/share/content-code.tsx @@ -0,0 +1,32 @@ +import { type JSX, splitProps, createResource, Suspense } from "solid-js" +import { codeToHtml } from "shiki" +import style from "./content-code.module.css" +import { transformerNotationDiff } from "@shikijs/transformers" + +interface Props { + code: string + lang?: string + flush?: boolean +} +export function ContentCode(props: Props) { + const [html] = createResource( + () => [props.code, props.lang], + async ([code, lang]) => { + // TODO: For testing delays + // await new Promise((resolve) => setTimeout(resolve, 3000)) + return (await codeToHtml(code || "", { + lang: lang || "text", + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [transformerNotationDiff()], + })) as string + }, + ) + return ( + <Suspense> + <div innerHTML={html()} class={style.root} data-flush={props.flush === true ? true : undefined} /> + </Suspense> + ) +} diff --git a/packages/web/src/components/share/content-diff.module.css b/packages/web/src/components/share/content-diff.module.css new file mode 100644 index 000000000..718ae3690 --- /dev/null +++ b/packages/web/src/components/share/content-diff.module.css @@ -0,0 +1,125 @@ +.root { + display: flex; + flex-direction: column; + border: 1px solid var(--sl-color-divider); + background-color: var(--sl-color-bg-surface); + border-radius: 0.25rem; + + [data-component="desktop"] { + display: block; + } + + [data-component="mobile"] { + display: none; + } + + [data-component="diff-block"] { + display: flex; + flex-direction: column; + } + + [data-component="diff-row"] { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: stretch; + + [data-slot="before"], + [data-slot="after"] { + position: relative; + display: flex; + flex-direction: column; + overflow-x: visible; + min-width: 0; + align-items: stretch; + padding: 0 1rem; + + &[data-diff-type="removed"] { + background-color: var(--sl-color-red-low); + + pre { + --shiki-dark-bg: var(--sl-color-red-low) !important; + background-color: var(--sl-color-red-low) !important; + } + + &::before { + content: "-"; + position: absolute; + left: 0.5ch; + top: 1px; + user-select: none; + color: var(--sl-color-red-high); + } + } + + &[data-diff-type="added"] { + background-color: var(--sl-color-green-low); + + pre { + --shiki-dark-bg: var(--sl-color-green-low) !important; + background-color: var(--sl-color-green-low) !important; + } + + &::before { + content: "+"; + position: absolute; + user-select: none; + color: var(--sl-color-green-high); + left: 0.5ch; + top: 1px; + } + } + } + + [data-slot="before"] { + border-right: 1px solid var(--sl-color-divider); + } + } + + .diff > .row:first-child [data-section="cell"]:first-child { + padding-top: 0.5rem; + } + + .diff > .row:last-child [data-section="cell"]:last-child { + padding-bottom: 0.5rem; + } + + [data-section="cell"] { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + + width: 100%; + padding: 0.1875rem 0.5rem 0.1875rem 2.2ch; + margin: 0; + + &[data-display-mobile="true"] { + display: none; + } + + pre { + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + background-color: var(--sl-color-bg-surface) !important; + + white-space: pre-wrap; + word-break: break-word; + + code > span:empty::before { + content: "\00a0"; + white-space: pre; + display: inline-block; + width: 0; + } + } + } + + @media (max-width: 40rem) { + [data-slot="desktop"] { + display: none; + } + + [data-slot="mobile"] { + display: block; + } + } +} diff --git a/packages/web/src/components/share/content-diff.tsx b/packages/web/src/components/share/content-diff.tsx new file mode 100644 index 000000000..894145c34 --- /dev/null +++ b/packages/web/src/components/share/content-diff.tsx @@ -0,0 +1,231 @@ +import { type Component, createMemo } from "solid-js" +import { parsePatch } from "diff" +import { ContentCode } from "./content-code" +import styles from "./content-diff.module.css" + +type DiffRow = { + left: string + right: string + type: "added" | "removed" | "unchanged" | "modified" +} + +interface Props { + diff: string + lang?: string +} + +export function ContentDiff(props: Props) { + const rows = createMemo(() => { + const diffRows: DiffRow[] = [] + + try { + const patches = parsePatch(props.diff) + + for (const patch of patches) { + for (const hunk of patch.hunks) { + const lines = hunk.lines + let i = 0 + + while (i < lines.length) { + const line = lines[i] + const content = line.slice(1) + const prefix = line[0] + + if (prefix === "-") { + // Look ahead for consecutive additions to pair with removals + const removals: string[] = [content] + let j = i + 1 + + // Collect all consecutive removals + while (j < lines.length && lines[j][0] === "-") { + removals.push(lines[j].slice(1)) + j++ + } + + // Collect all consecutive additions that follow + const additions: string[] = [] + while (j < lines.length && lines[j][0] === "+") { + additions.push(lines[j].slice(1)) + j++ + } + + // Pair removals with additions + const maxLength = Math.max(removals.length, additions.length) + for (let k = 0; k < maxLength; k++) { + const hasLeft = k < removals.length + const hasRight = k < additions.length + + if (hasLeft && hasRight) { + // Replacement - left is removed, right is added + diffRows.push({ + left: removals[k], + right: additions[k], + type: "modified", + }) + } else if (hasLeft) { + // Pure removal + diffRows.push({ + left: removals[k], + right: "", + type: "removed", + }) + } else if (hasRight) { + // Pure addition - only create if we actually have content + diffRows.push({ + left: "", + right: additions[k], + type: "added", + }) + } + } + + i = j + } else if (prefix === "+") { + // Standalone addition (not paired with removal) + diffRows.push({ + left: "", + right: content, + type: "added", + }) + i++ + } else if (prefix === " ") { + diffRows.push({ + left: content, + right: content, + type: "unchanged", + }) + i++ + } else { + i++ + } + } + } + } + } catch (error) { + console.error("Failed to parse patch:", error) + return [] + } + + return diffRows + }) + + const mobileRows = createMemo(() => { + const mobileBlocks: { type: "removed" | "added" | "unchanged"; lines: string[] }[] = [] + const currentRows = rows() + + let i = 0 + while (i < currentRows.length) { + const removedLines: string[] = [] + const addedLines: string[] = [] + + // Collect consecutive modified/removed/added rows + while ( + i < currentRows.length && + (currentRows[i].type === "modified" || currentRows[i].type === "removed" || currentRows[i].type === "added") + ) { + const row = currentRows[i] + if (row.left && (row.type === "removed" || row.type === "modified")) { + removedLines.push(row.left) + } + if (row.right && (row.type === "added" || row.type === "modified")) { + addedLines.push(row.right) + } + i++ + } + + // Add grouped blocks + if (removedLines.length > 0) { + mobileBlocks.push({ type: "removed", lines: removedLines }) + } + if (addedLines.length > 0) { + mobileBlocks.push({ type: "added", lines: addedLines }) + } + + // Add unchanged rows as-is + if (i < currentRows.length && currentRows[i].type === "unchanged") { + mobileBlocks.push({ + type: "unchanged", + lines: [currentRows[i].left], + }) + i++ + } + } + + return mobileBlocks + }) + + return ( + <div class={styles.root}> + <div data-component="desktop"> + {rows().map((r) => ( + <div data-component="diff-row" data-type={r.type}> + <div data-slot="before" data-diff-type={r.type === "removed" || r.type === "modified" ? "removed" : ""}> + <ContentCode code={r.left} flush lang={props.lang} /> + </div> + <div data-slot="after" data-diff-type={r.type === "added" || r.type === "modified" ? "added" : ""}> + <ContentCode code={r.right} lang={props.lang} flush /> + </div> + </div> + ))} + </div> + + <div data-component="mobile"> + {mobileRows().map((block) => ( + <div data-component="diff-block" data-type={block.type}> + {block.lines.map((line) => ( + <ContentCode + code={line} + lang={props.lang} + data-section="cell" + data-diff-type={block.type === "removed" ? "removed" : block.type === "added" ? "added" : ""} + /> + ))} + </div> + ))} + </div> + </div> + ) +} + +// const testDiff = `--- combined_before.txt 2025-06-24 16:38:08 +// +++ combined_after.txt 2025-06-24 16:38:12 +// @@ -1,21 +1,25 @@ +// unchanged line +// -deleted line +// -old content +// +added line +// +new content +// +// -removed empty line below +// +added empty line above +// +// - tab indented +// -trailing spaces +// -very long line that will definitely wrap in most editors and cause potential alignment issues when displayed in a two column diff view +// -unicode content: π β¨ δΈζ +// -mixed content with tabs and spaces +// + space indented +// +no trailing spaces +// +short line +// +very long replacement line that will also wrap and test how the diff viewer handles long line additions after short line removals +// +different unicode: π π» ζ₯ζ¬θͺ +// +normalized content with consistent spacing +// +newline to content +// +// -content to remove +// -whitespace only: +// -multiple +// -consecutive +// -deletions +// -single deletion +// + +// +single addition +// +first addition +// +second addition +// +third addition +// line before addition +// +first added line +// + +// +third added line +// line after addition +// final unchanged line` diff --git a/packages/web/src/components/share/content-markdown.module.css b/packages/web/src/components/share/content-markdown.module.css new file mode 100644 index 000000000..da1aa1128 --- /dev/null +++ b/packages/web/src/components/share/content-markdown.module.css @@ -0,0 +1,140 @@ +.root { + border: 1px solid var(--sl-color-blue-high); + padding: 0.5rem calc(0.5rem + 3px); + border-radius: 0.25rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1rem; + align-self: flex-start; + max-width: var(--md-tool-width); + + &[data-highlight="true"] { + background-color: var(--sl-color-blue-low); + } + + [data-slot="expand-button"] { + flex: 0 0 auto; + padding: 2px 0; + font-size: 0.75rem; + } + + [data-slot="markdown"] { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + + [data-expanded] & { + display: block; + } + + font-size: 0.875rem; + line-height: 1.5; + + p, + blockquote, + ul, + ol, + dl, + table, + pre { + margin-bottom: 1rem; + } + + strong { + font-weight: 600; + } + + ol { + list-style-position: inside; + padding-left: 0.75rem; + } + + ul { + padding-left: 1.5rem; + } + + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + & > *:last-child { + margin-bottom: 0; + } + + pre { + --shiki-dark-bg: var(--sl-color-bg-surface) !important; + background-color: var(--sl-color-bg-surface) !important; + padding: 0.5rem 0.75rem; + line-height: 1.6; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + + span { + white-space: break-spaces; + } + } + + code { + font-weight: 500; + + &:not(pre code) { + &::before { + content: "`"; + font-weight: 700; + } + + &::after { + content: "`"; + font-weight: 700; + } + } + } + + table { + border-collapse: collapse; + width: 100%; + } + + th, + td { + border: 1px solid var(--sl-color-border); + padding: 0.5rem 0.75rem; + text-align: left; + } + + th { + border-bottom: 1px solid var(--sl-color-border); + } + + /* Remove outer borders */ + table tr:first-child th, + table tr:first-child td { + border-top: none; + } + + table tr:last-child td { + border-bottom: none; + } + + table th:first-child, + table td:first-child { + border-left: none; + } + + table th:last-child, + table td:last-child { + border-right: none; + } + } +} diff --git a/packages/web/src/components/share/content-markdown.tsx b/packages/web/src/components/share/content-markdown.tsx new file mode 100644 index 000000000..f79271296 --- /dev/null +++ b/packages/web/src/components/share/content-markdown.tsx @@ -0,0 +1,65 @@ +import style from "./content-markdown.module.css" +import { createResource, createSignal } from "solid-js" +import { createOverflow } from "./common" +import { transformerNotationDiff } from "@shikijs/transformers" +import { marked } from "marked" +import markedShiki from "marked-shiki" +import { codeToHtml } from "shiki" + +const markedWithShiki = marked.use( + markedShiki({ + highlight(code, lang) { + return codeToHtml(code, { + lang: lang || "text", + themes: { + light: "github-light", + dark: "github-dark", + }, + transformers: [transformerNotationDiff()], + }) + }, + }), +) + +interface Props { + text: string + expand?: boolean + highlight?: boolean +} +export function ContentMarkdown(props: Props) { + const [html] = createResource( + () => strip(props.text), + async (markdown) => { + return markedWithShiki.parse(markdown) + }, + ) + const [expanded, setExpanded] = createSignal(false) + const overflow = createOverflow() + + return ( + <div + class={style.root} + data-highlight={props.highlight === true ? true : undefined} + data-expanded={expanded() || props.expand === true ? true : undefined} + > + <div data-slot="markdown" ref={overflow.ref} innerHTML={html()} /> + + {!props.expand && overflow.status && ( + <button + type="button" + data-component="text-button" + data-slot="expand-button" + onClick={() => setExpanded((e) => !e)} + > + {expanded() ? "Show less" : "Show more"} + </button> + )} + </div> + ) +} + +function strip(text: string): string { + const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/ + const match = text.match(wrappedRe) + return match ? match[2] : text +} diff --git a/packages/web/src/components/share/content-text.module.css b/packages/web/src/components/share/content-text.module.css new file mode 100644 index 000000000..f8d0b0b93 --- /dev/null +++ b/packages/web/src/components/share/content-text.module.css @@ -0,0 +1,57 @@ +.root { + color: var(--sl-color-text); + background-color: var(--sl-color-bg-surface); + padding: 0.5rem calc(0.5rem + 3px); + border-radius: 0.25rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1rem; + align-self: flex-start; + max-width: var(--md-tool-width); + font-size: 0.875rem; + + &[data-compact] { + font-size: 0.75rem; + color: var(--sl-color-text-dimmed); + } + + [data-slot="text"] { + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + overflow: hidden; + + [data-expanded] & { + display: block; + } + } + + [data-slot="expand-button"] { + flex: 0 0 auto; + padding: 2px 0; + font-size: 0.75rem; + } + + &[data-theme="invert"] { + background-color: var(--sl-color-blue-high); + color: var(--sl-color-text-invert); + + [data-slot="expand-button"] { + opacity: 0.85; + color: var(--sl-color-text-invert); + + &:hover { + opacity: 1; + } + } + } + + &[data-theme="blue"] { + background-color: var(--sl-color-blue-low); + } +} diff --git a/packages/web/src/components/share/content-text.tsx b/packages/web/src/components/share/content-text.tsx new file mode 100644 index 000000000..c52e0dfcc --- /dev/null +++ b/packages/web/src/components/share/content-text.tsx @@ -0,0 +1,35 @@ +import style from "./content-text.module.css" +import { createSignal } from "solid-js" +import { createOverflow } from "./common" + +interface Props { + text: string + expand?: boolean + compact?: boolean +} +export function ContentText(props: Props) { + const [expanded, setExpanded] = createSignal(false) + const overflow = createOverflow() + + return ( + <div + class={style.root} + data-expanded={expanded() || props.expand === true ? true : undefined} + data-compact={props.compact === true ? true : undefined} + > + <pre data-slot="text" ref={overflow.ref}> + {props.text} + </pre> + {((!props.expand && overflow.status) || expanded()) && ( + <button + type="button" + data-component="text-button" + data-slot="expand-button" + onClick={() => setExpanded((e) => !e)} + > + {expanded() ? "Show less" : "Show more"} + </button> + )} + </div> + ) +} diff --git a/packages/web/src/components/share/part.module.css b/packages/web/src/components/share/part.module.css new file mode 100644 index 000000000..9145cddf7 --- /dev/null +++ b/packages/web/src/components/share/part.module.css @@ -0,0 +1,375 @@ +.root { + display: flex; + gap: 0.625rem; + + [data-component="decoration"] { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 0.625rem; + align-items: center; + justify-content: flex-start; + + [data-slot="anchor"] { + position: relative; + + a:first-child { + display: block; + flex: 0 0 auto; + width: 18px; + opacity: 0.65; + + svg { + color: var(--sl-color-text-secondary); + display: block; + + &:nth-child(3) { + color: var(--sl-color-green-high); + } + } + + svg:nth-child(2), + svg:nth-child(3) { + display: none; + } + + &:hover { + svg:nth-child(1) { + display: none; + } + + svg:nth-child(2) { + display: block; + } + } + } + + [data-copied] & { + a, + a:hover { + svg:nth-child(1), + svg:nth-child(2) { + display: none; + } + + svg:nth-child(3) { + display: block; + } + } + } + } + + [data-slot="bar"] { + width: 3px; + height: 100%; + border-radius: 1px; + background-color: var(--sl-color-hairline); + } + + [data-slot="tooltip"] { + position: absolute; + top: 50%; + left: calc(100% + 12px); + transform: translate(0, -50%); + line-height: 1.1; + padding: 0.375em 0.5em calc(0.375em + 2px); + background: var(--sl-color-white); + color: var(--sl-color-text-invert); + font-size: 0.6875rem; + border-radius: 7px; + white-space: nowrap; + + z-index: 1; + opacity: 0; + visibility: hidden; + + &::after { + content: ""; + position: absolute; + top: 50%; + left: -15px; + transform: translateY(-50%); + border: 8px solid transparent; + border-right-color: var(--sl-color-white); + } + + [data-copied] & { + opacity: 1; + visibility: visible; + } + } + } + + [data-component="content"] { + display: flex; + flex-direction: column; + gap: 1rem; + flex-grow: 1; + } + + [data-component="spacer"] { + height: 0rem; + } + + [data-component="content-footer"] { + align-self: flex-start; + font-size: 0.75rem; + color: var(--sl-color-text-dimmed); + } + + [data-component="step-start"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; + padding-bottom: 1rem; + + [data-slot="provider"] { + line-height: 18px; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: -0.5px; + color: var(--sl-color-text-secondary); + } + + [data-slot="model"] { + line-height: 1.5; + } + } + + [data-component="button-text"] { + cursor: pointer; + appearance: none; + background-color: transparent; + border: none; + padding: 0; + color: var(--sl-color-text-secondary); + font-size: 0.75rem; + + &:hover { + color: var(--sl-color-text); + } + + &[data-more] { + display: flex; + align-items: center; + gap: 0.125rem; + + span[data-slot="icon"] { + line-height: 1; + opacity: 0.85; + + svg { + display: block; + } + } + } + } + + [data-component="tool"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.375rem; + padding-bottom: 1rem; + } + + [data-component="tool-title"] { + line-height: 18px; + font-size: 0.875rem; + color: var(--sl-color-text-secondary); + max-width: var(--md-tool-width); + display: flex; + align-items: flex-start; + gap: 0.375rem; + + [data-slot="name"] { + text-transform: uppercase; + letter-spacing: -0.5px; + } + + [data-slot="target"] { + color: var(--sl-color-text); + word-break: break-all; + font-weight: 500; + } + } + + [data-component="tool-result"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + [data-component="todos"] { + list-style-type: none; + padding: 0; + margin: 0; + width: 100%; + max-width: var(--sm-tool-width); + border: 1px solid var(--sl-color-divider); + border-radius: 0.25rem; + + [data-slot="item"] { + margin: 0; + position: relative; + padding-left: 1.5rem; + font-size: 0.75rem; + padding: 0.375rem 0.625rem 0.375rem 1.75rem; + border-bottom: 1px solid var(--sl-color-divider); + line-height: 1.5; + word-break: break-word; + + &:last-child { + border-bottom: none; + } + + & > span { + position: absolute; + display: inline-block; + left: 0.5rem; + top: calc(0.5rem + 1px); + width: 0.75rem; + height: 0.75rem; + border: 1px solid var(--sl-color-divider); + border-radius: 0.15rem; + + &::before { + } + } + + &[data-status="pending"] { + color: var(--sl-color-text); + } + + &[data-status="in_progress"] { + color: var(--sl-color-text); + + & > span { + border-color: var(--sl-color-orange); + } + + & > span::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: calc(0.75rem - 2px - 4px); + height: calc(0.75rem - 2px - 4px); + box-shadow: inset 1rem 1rem var(--sl-color-orange-low); + } + } + + &[data-status="completed"] { + color: var(--sl-color-text-secondary); + + & > span { + border-color: var(--sl-color-green-low); + } + + & > span::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: calc(0.75rem - 2px - 4px); + height: calc(0.75rem - 2px - 4px); + box-shadow: inset 1rem 1rem var(--sl-color-green); + + transform-origin: bottom left; + clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); + } + } + } + } + + [data-component="terminal"] { + width: 100%; + max-width: var(--sm-tool-width); + + [data-slot="body"] { + display: flex; + flex-direction: column; + border: 1px solid var(--sl-color-divider); + border-radius: 0.25rem; + overflow: hidden; + } + + [data-slot="header"] { + position: relative; + border-bottom: 1px solid var(--sl-color-divider); + width: 100%; + height: 1.625rem; + text-align: center; + padding: 0 3.25rem; + + > span { + max-width: min(100%, 140ch); + display: inline-block; + white-space: nowrap; + overflow: hidden; + line-height: 1.625rem; + font-size: 0.75rem; + text-overflow: ellipsis; + color: var(--sl-color-text-dimmed); + } + + &::before { + content: ""; + position: absolute; + pointer-events: none; + top: 8px; + left: 10px; + width: 2rem; + height: 0.5rem; + line-height: 0; + background-color: var(--sl-color-hairline); + mask-image: var(--term-icon); + mask-repeat: no-repeat; + } + } + + [data-slot="content"] { + display: flex; + flex-direction: column; + padding: 0.5rem calc(0.5rem + 3px); + + pre { + --shiki-dark-bg: var(--sl-color-bg) !important; + background-color: var(--sl-color-bg) !important; + line-height: 1.6; + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; + } + } + } + + [data-component="tool-args"] { + display: inline-grid; + align-items: center; + grid-template-columns: max-content max-content minmax(0, 1fr); + max-width: var(--md-tool-width); + gap: 0.25rem 0.375rem; + + & > div:nth-child(3n + 1) { + width: 8px; + height: 2px; + border-radius: 1px; + background: var(--sl-color-divider); + } + + & > div:nth-child(3n + 2), + & > div:nth-child(3n + 3) { + font-size: 0.75rem; + line-height: 1.5; + } + + & > div:nth-child(3n + 3) { + padding-left: 0.125rem; + word-break: break-word; + color: var(--sl-color-text-secondary); + } + } +} diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx new file mode 100644 index 000000000..3ee2c61a3 --- /dev/null +++ b/packages/web/src/components/share/part.tsx @@ -0,0 +1,664 @@ +import { createMemo, createSignal, For, Match, Show, Switch, type JSX, type ParentProps } from "solid-js" +import { + IconCheckCircle, + IconChevronDown, + IconChevronRight, + IconHashtag, + IconSparkles, + IconGlobeAlt, + IconDocument, + IconQueueList, + IconCommandLine, + IconDocumentPlus, + IconPencilSquare, + IconRectangleStack, + IconMagnifyingGlass, + IconDocumentMagnifyingGlass, +} from "../icons" +import styles from "./part.module.css" +import type { MessageV2 } from "opencode/session/message-v2" +import { ContentText } from "./content-text" +import { ContentMarkdown } from "./content-markdown" +import { DateTime } from "luxon" +import CodeBlock from "../CodeBlock" +import map from "lang-map" +import type { Diagnostic } from "vscode-languageserver-types" + +import { ContentCode } from "./content-code" +import { ContentDiff } from "./content-diff" + +export interface PartProps { + index: number + message: MessageV2.Info + part: MessageV2.AssistantPart | MessageV2.UserPart + last: boolean +} + +export function Part(props: PartProps) { + const [copied, setCopied] = createSignal(false) + const id = createMemo(() => props.message.id + "-" + props.index) + + return ( + <div + class={styles.root} + id={id()} + data-component="part" + data-type={props.part.type} + data-role={props.message.role} + data-copied={copied() ? true : undefined} + > + <div data-component="decoration"> + <div data-slot="anchor" title="Link to this message"> + <a + href={`#${id()}`} + onClick={(e) => { + e.preventDefault() + const anchor = e.currentTarget + const hash = anchor.getAttribute("href") || "" + const { origin, pathname, search } = window.location + navigator.clipboard + .writeText(`${origin}${pathname}${search}${hash}`) + .catch((err) => console.error("Copy failed", err)) + + setCopied(true) + setTimeout(() => setCopied(false), 3000) + }} + > + <Switch> + <Match when={props.part.type === "tool" && props.part.tool === "todowrite"}> + <IconQueueList width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "todoread"}> + <IconQueueList width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "bash"}> + <IconCommandLine width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "edit"}> + <IconPencilSquare width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "write"}> + <IconDocumentPlus width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "read"}> + <IconDocument width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "grep"}> + <IconDocumentMagnifyingGlass width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "list"}> + <IconRectangleStack width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "glob"}> + <IconMagnifyingGlass width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "webfetch"}> + <IconGlobeAlt width={18} height={18} /> + </Match> + <Match when={props.part.type === "tool" && props.part.tool === "task"}> + <IconRectangleStack width={18} height={18} /> + </Match> + <Match when={true}> + <IconSparkles width={18} height={18} /> + </Match> + </Switch> + <IconHashtag width={18} height={18} /> + <IconCheckCircle width={18} height={18} /> + </a> + <span data-slot="tooltip">Copied!</span> + </div> + <div data-slot="bar"></div> + </div> + <div data-component="content"> + {props.message.role === "user" && props.part.type === "text" && ( + <> + <ContentText text={props.part.text} expand={props.last} /> <Spacer /> + </> + )} + {props.message.role === "assistant" && props.part.type === "text" && ( + <> + <ContentMarkdown expand={props.last} text={props.part.text} /> + {props.last && props.message.role === "assistant" && props.message.time.completed && ( + <Footer + title={DateTime.fromMillis(props.message.time.completed).toLocaleString( + DateTime.DATETIME_FULL_WITH_SECONDS, + )} + > + {DateTime.fromMillis(props.message.time.completed).toLocaleString(DateTime.DATETIME_MED)} + </Footer> + )} + <Spacer /> + </> + )} + {props.part.type === "step-start" && props.message.role === "assistant" && ( + <div data-component="step-start"> + <div data-slot="provider">{props.message.providerID}</div> + <div data-slot="model">{props.message.modelID}</div> + </div> + )} + {props.part.type === "tool" && + props.part.state.status === "completed" && + props.message.role === "assistant" && ( + <div data-component="tool" data-tool={props.part.tool}> + <Switch> + <Match when={props.part.tool === "grep"}> + <GrepTool + message={props.message} + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + /> + </Match> + <Match when={props.part.tool === "glob"}> + <GlobTool + message={props.message} + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + /> + </Match> + <Match when={props.part.tool === "list"}> + <ListTool + message={props.message} + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + /> + </Match> + <Match when={props.part.tool === "read"}> + <ReadTool + message={props.message} + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + /> + </Match> + <Match when={props.part.tool === "write"}> + <WriteTool + message={props.message} + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + /> + </Match> + <Match when={props.part.tool === "edit"}> + <EditTool + message={props.message} + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + /> + </Match> + <Match when={props.part.tool === "bash"}> + <BashTool + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + message={props.message} + /> + </Match> + <Match when={props.part.tool === "todowrite"}> + <TodoWriteTool + message={props.message} + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + /> + </Match> + <Match when={props.part.tool === "webfetch"}> + <WebFetchTool + message={props.message} + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + /> + </Match> + <Match when={true}> + <FallbackTool + message={props.message} + id={props.part.id} + tool={props.part.tool} + state={props.part.state} + /> + </Match> + </Switch> + </div> + )} + </div> + </div> + ) +} + +type ToolProps = { + id: MessageV2.ToolPart["id"] + tool: MessageV2.ToolPart["tool"] + state: MessageV2.ToolStateCompleted + message: MessageV2.Assistant + isLastPart?: boolean +} + +interface Todo { + id: string + content: string + status: "pending" | "in_progress" | "completed" + priority: "low" | "medium" | "high" +} + +function stripWorkingDirectory(filePath?: string, workingDir?: string) { + if (filePath === undefined || workingDir === undefined) return filePath + + const prefix = workingDir.endsWith("/") ? workingDir : workingDir + "/" + + if (filePath === workingDir) { + return "" + } + + if (filePath.startsWith(prefix)) { + return filePath.slice(prefix.length) + } + + return filePath +} + +function getShikiLang(filename: string) { + const ext = filename.split(".").pop()?.toLowerCase() ?? "" + const langs = map.languages(ext) + const type = langs?.[0]?.toLowerCase() + + const overrides: Record<string, string> = { + conf: "shellscript", + } + + return type ? (overrides[type] ?? type) : "plaintext" +} + +function getDiagnostics(diagnosticsByFile: Record<string, Diagnostic[]>, currentFile: 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>, + ) + } + } + + return result +} + +function formatErrorString(error: string): JSX.Element { + const errorMarker = "Error: " + const startsWithError = error.startsWith(errorMarker) + + return startsWithError ? ( + <pre> + <span data-color="red" data-marker="label" data-separator> + Error + </span> + <span>{error.slice(errorMarker.length)}</span> + </pre> + ) : ( + <pre> + <span data-color="dimmed">{error}</span> + </pre> + ) +} + +export function TodoWriteTool(props: ToolProps) { + const priority: Record<Todo["status"], number> = { + in_progress: 0, + pending: 1, + completed: 2, + } + const todos = createMemo(() => + ((props.state.input?.todos ?? []) as Todo[]).slice().sort((a, b) => priority[a.status] - priority[b.status]), + ) + const starting = () => todos().every((t: Todo) => t.status === "pending") + const finished = () => todos().every((t: Todo) => t.status === "completed") + + return ( + <> + <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> + </span> + </div> + <Show when={todos().length > 0}> + <ul data-component="todos"> + <For each={todos()}> + {(todo) => ( + <li data-slot="item" data-status={todo.status}> + <span></span> + {todo.content} + </li> + )} + </For> + </ul> + </Show> + </> + ) +} + +export function GrepTool(props: ToolProps) { + return ( + <> + <div data-component="tool-title"> + <span data-slot="name">Grep</span> + <span data-slot="target">“{props.state.input.pattern}”</span> + </div> + <div data-component="tool-result"> + <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`} + > + <ContentText expand compact text={props.state.output} /> + </ResultsButton> + </Match> + <Match when={props.state.output}> + <ContentText expand compact text={props.state.output} data-size="sm" data-color="dimmed" /> + </Match> + </Switch> + </div> + </> + ) +} + +export function ListTool(props: ToolProps) { + const path = createMemo(() => + props.state.input?.path !== props.message.path.cwd + ? stripWorkingDirectory(props.state.input?.path, props.message.path.cwd) + : props.state.input?.path, + ) + + return ( + <> + <div data-component="tool-title"> + <span data-slot="name">LS</span> + <span data-slot="target" title={props.state.input?.path}> + {path()} + </span> + </div> + <div data-component="tool-result"> + <Switch> + <Match when={props.state.output}> + <ResultsButton> + <ContentText expand compact text={props.state.output} /> + </ResultsButton> + </Match> + </Switch> + </div> + </> + ) +} + +export function WebFetchTool(props: ToolProps) { + return ( + <> + <div data-component="tool-title"> + <span data-slot="name">Fetch</span> + <span data-slot="target">{props.state.input.url}</span> + </div> + <div data-component="tool-result"> + <Switch> + <Match when={props.state.metadata?.error}> + <div data-component="error">{formatErrorString(props.state.output)}</div> + </Match> + <Match when={props.state.output}> + <ResultsButton> + <CodeBlock lang={props.state.input.format || "text"} code={props.state.output} /> + </ResultsButton> + </Match> + </Switch> + </div> + </> + ) +} + +export function ReadTool(props: ToolProps) { + const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) + + return ( + <> + <div data-component="tool-title"> + <span data-slot="name">Read</span> + <span data-slot="target" title={props.state.input?.filePath}> + {filePath()} + </span> + </div> + <div data-component="tool-result"> + <Switch> + <Match when={props.state.metadata?.error}> + <div data-component="error">{formatErrorString(props.state.output)}</div> + </Match> + <Match when={typeof props.state.metadata?.preview === "string"}> + <ResultsButton showCopy="Show preview" hideCopy="Hide preview"> + <ContentCode lang={getShikiLang(filePath() || "")} code={props.state.metadata?.preview} /> + </ResultsButton> + </Match> + <Match when={typeof props.state.metadata?.preview !== "string" && props.state.output}> + <ResultsButton> + <ContentText expand compact text={props.state.output} /> + </ResultsButton> + </Match> + </Switch> + </div> + </> + ) +} + +export function WriteTool(props: ToolProps) { + const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) + const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + + return ( + <> + <div data-component="tool-title"> + <span data-slot="name">Write</span> + <span data-slot="target" title={props.state.input?.filePath}> + {filePath()} + </span> + </div> + <Show when={diagnostics().length > 0}> + <div data-component="error">{diagnostics()}</div> + </Show> + <div data-component="tool-result"> + <Switch> + <Match when={props.state.metadata?.error}> + <div data-component="error">{formatErrorString(props.state.output)}</div> + </Match> + <Match when={props.state.input?.content}> + <ResultsButton showCopy="Show contents" hideCopy="Hide contents"> + <ContentCode lang={getShikiLang(filePath() || "")} code={props.state.input?.content} /> + </ResultsButton> + </Match> + </Switch> + </div> + </> + ) +} + +export function EditTool(props: ToolProps) { + const filePath = createMemo(() => stripWorkingDirectory(props.state.input.filePath, props.message.path.cwd)) + const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + + return ( + <> + <div data-component="tool-title"> + <span data-slot="name">Edit</span> + <span data-slot="target" title={props.state.input?.filePath}> + {filePath()} + </span> + </div> + <div data-component="tool-result"> + <Switch> + <Match when={props.state.metadata?.error}> + <div data-component="error">{formatErrorString(props.state.metadata?.message || "")}</div> + </Match> + <Match when={props.state.metadata?.diff}> + <div data-component="diff"> + <ContentDiff diff={props.state.metadata?.diff} lang={getShikiLang(filePath() || "")} /> + </div> + </Match> + </Switch> + </div> + <Show when={diagnostics().length > 0}> + <div data-component="error">{diagnostics()}</div> + </Show> + </> + ) +} + +export function BashTool(props: ToolProps) { + return ( + <> + <div data-component="terminal" data-size="sm"> + <div data-slot="body"> + <div data-slot="header"> + <span>{props.state.metadata.description}</span> + </div> + <div data-slot="content"> + <ContentCode flush lang="bash" code={props.state.input.command} /> + <ContentCode flush lang="console" code={props.state.metadata?.stdout || ""} /> + </div> + </div> + </div> + </> + ) +} + +export function GlobTool(props: ToolProps) { + return ( + <> + <div data-component="tool-title"> + <span data-slot="name">Glob</span> + <span data-slot="target">“{props.state.input.pattern}”</span> + </div> + <Switch> + <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`} + > + <ContentText expand compact text={props.state.output} /> + </ResultsButton> + </div> + </Match> + <Match when={props.state.output}> + <ContentText expand text={props.state.output} data-size="sm" data-color="dimmed" /> + </Match> + </Switch> + </> + ) +} + +interface ResultsButtonProps extends ParentProps { + showCopy?: string + hideCopy?: string +} +function ResultsButton(props: ResultsButtonProps) { + const [show, setShow] = createSignal(false) + + 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 data-slot="icon"> + <Show when={show()} fallback={<IconChevronRight width={11} height={11} />}> + <IconChevronDown width={11} height={11} /> + </Show> + </span> + </button> + <Show when={show()}>{props.children}</Show> + </> + ) +} + +export function Spacer() { + return <div data-component="spacer"></div> +} + +function Footer(props: ParentProps<{ title: string }>) { + return ( + <div data-component="content-footer" title={props.title}> + {props.children} + </div> + ) +} + +export function FallbackTool(props: ToolProps) { + return ( + <> + <div data-component="tool-title"> + <span data-slot="name">{props.tool}</span> + </div> + <div data-component="tool-args"> + <For each={flattenToolArgs(props.state.input)}> + {(arg) => ( + <> + <div></div> + <div>{arg[0]}</div> + <div>{arg[1]}</div> + </> + )} + </For> + </div> + <Switch> + <Match when={props.state.output}> + <div data-component="tool-result"> + <ResultsButton> + <ContentText expand compact text={props.state.output} data-size="sm" data-color="dimmed" /> + </ResultsButton> + </div> + </Match> + </Switch> + </> + ) +} + +// 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]> = [] + + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key + + if (value !== null && typeof value === "object") { + if (Array.isArray(value)) { + value.forEach((item, index) => { + const arrayPath = `${path}[${index}]` + if (item !== null && typeof item === "object") { + entries.push(...flattenToolArgs(item, arrayPath)) + } else { + entries.push([arrayPath, item]) + } + }) + } else { + entries.push(...flattenToolArgs(value, path)) + } + } else { + entries.push([path, value]) + } + } + + return entries +} |
