diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 01eea0f55821..7ac1aa9965d6 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -1,9 +1,9 @@ -import { Match, Show, Switch, createMemo } from "solid-js" -import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" +import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2" import { Button } from "@opencode-ai/ui/button" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" @@ -18,7 +18,16 @@ import { createSessionTabs } from "@/pages/session/helpers" interface SessionContextUsageProps { variant?: "button" | "indicator" buttonAppearance?: "default" | "v2" - placement?: TooltipProps["placement"] + placement?: ComponentProps["placement"] +} + +function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) { + return ( +
+ {props.name} + {props.value} +
+ ) } function openSessionContext(args: { @@ -113,38 +122,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) const tooltipValue = () => ( -
- - {(value) => ( -
- - {getSessionTokenTotal(value())?.toLocaleString(language.intl())} - - {language.t("context.usage.tokens")} -
- )} -
- - {(ctx) => ( -
- {ctx().usage ?? 0}% - {language.t("context.usage.usage")} -
- )} -
-
- {cost()} - {language.t("context.usage.cost")} -
+
+ + +
) return ( - - {circle()} - - + + + {circle()} + - - - - + + - - - + + + ) } diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index a7db67a64e9c..577b68dadc1e 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -192,65 +192,22 @@ opacity: 0; transition: opacity 0.15s ease; z-index: 1; - - &::after { - content: attr(data-tooltip); - position: absolute; - left: 50%; - bottom: calc(100% + 4px); - transform: translateX(-50%); - z-index: 1000; - - max-width: 320px; - border-radius: var(--radius-sm); - background: var(--surface-float-base); - color: var(--text-invert-strong); - padding: 2px 8px; - border: 0.5px solid var(--v2-border-border-base, rgba(0, 0, 0, 0.07)); - box-shadow: var(--shadow-md); - - pointer-events: none; - white-space: nowrap; - - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - letter-spacing: var(--letter-spacing-normal); - - opacity: 0; - transition: opacity 0.15s ease; - } } - [data-slot="markdown-copy-button"]:hover::after, - [data-slot="markdown-copy-button"]:focus-visible::after { + [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"], + [data-slot="markdown-copy-button"]:focus-within { opacity: 1; } - [data-slot="markdown-copy-button"][data-variant="secondary"] { - box-shadow: none; - border: 0.5px solid var(--v2-border-border-base); - } - - [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { - color: var(--v2-icon-icon-base); - } - - [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { - opacity: 1; - } - - [data-slot="markdown-copy-button"] [data-slot="check-icon"] { + [data-slot="markdown-copy-button"] [data-check-icon] { display: none; } - [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] { + [data-slot="markdown-copy-button"][data-copied="true"] [data-copy-icon] { display: none; } - [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] { + [data-slot="markdown-copy-button"][data-copied="true"] [data-check-icon] { display: inline-flex; } @@ -389,18 +346,6 @@ body:not([data-new-layout]) [data-component="markdown"] { background: none; } - [data-slot="markdown-copy-button"]::after { - border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); - } - - [data-slot="markdown-copy-button"][data-variant="secondary"] { - border: 1px solid var(--border-weak-base); - } - - [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { - color: var(--icon-base); - } - th, td { border-bottom: 1px solid var(--border-weaker-base); diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index f0c312991878..02361f1db950 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -3,16 +3,21 @@ import { useI18n } from "@opencode-ai/ui/context/i18n" import morphdom from "morphdom" import { checksum } from "@opencode-ai/core/util/encode" import { - ComponentProps, + type Accessor, + type ComponentProps, createEffect, createMemo, createResource, createSignal, createUniqueId, onCleanup, + type Setter, splitProps, } from "solid-js" -import { isServer } from "solid-js/web" +import { isServer, render } from "solid-js/web" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { bundledLanguages } from "shiki" import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream" import { @@ -48,11 +53,6 @@ type RenderResult = { const renderedCodeTokens = new WeakMap() -const iconPaths = { - copy: '', - check: '', -} - function escape(text: string) { return text .replace(/&/g, "&") @@ -87,6 +87,14 @@ type CopyLabels = { copied: string } +type CopyButtonState = { + setLabels: Setter + setCopied: Setter + dispose: () => void +} + +const copyButtonState = new WeakMap() + const urlPattern = /^https?:\/\/[^\s<>()`"']+$/ function codeUrl(text: string) { @@ -100,45 +108,67 @@ function codeUrl(text: string) { } } -function createIcon(path: string, slot: string) { - const icon = document.createElement("div") - icon.setAttribute("data-component", "icon") - icon.setAttribute("data-size", "small") - icon.setAttribute("data-slot", slot) - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") - svg.setAttribute("data-slot", "icon-svg") - svg.setAttribute("fill", "none") - svg.setAttribute("viewBox", "0 0 20 20") - svg.setAttribute("aria-hidden", "true") - svg.innerHTML = path - icon.appendChild(svg) - return icon +function createCopyButton(labels: CopyLabels) { + const host = document.createElement("div") + host.setAttribute("data-slot", "markdown-copy-button") + + const state: Partial = {} + const dispose = render(() => { + const [labelState, setLabels] = createSignal(labels, { equals: false }) + const [copied, setCopied] = createSignal(false) + state.setLabels = setLabels + state.setCopied = setCopied + return + }, host) + state.dispose = dispose + copyButtonState.set(host, state as CopyButtonState) + return host } -function createCopyButton(labels: CopyLabels) { - const button = document.createElement("button") - button.type = "button" - button.setAttribute("data-component", "icon-button") - button.setAttribute("data-variant", "secondary") - button.setAttribute("data-size", "small") - button.setAttribute("data-slot", "markdown-copy-button") - button.setAttribute("aria-label", labels.copy) - button.setAttribute("data-tooltip", labels.copy) - button.appendChild(createIcon(iconPaths.copy, "copy-icon")) - button.appendChild(createIcon(iconPaths.check, "check-icon")) - return button +function MarkdownCopyButton(props: { labels: Accessor; copied: Accessor }) { + const label = () => (props.copied() ? props.labels().copied : props.labels().copy) + return ( + + + + + + } + /> + + ) } -function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) { +function setCopyState(host: HTMLElement, labels: CopyLabels, copied: boolean) { + const state = copyButtonState.get(host) + state?.setLabels(labels) + state?.setCopied(copied) if (copied) { - button.setAttribute("data-copied", "true") - button.setAttribute("aria-label", labels.copied) - button.setAttribute("data-tooltip", labels.copied) + host.setAttribute("data-copied", "true") return } - button.removeAttribute("data-copied") - button.setAttribute("aria-label", labels.copy) - button.setAttribute("data-tooltip", labels.copy) + host.removeAttribute("data-copied") +} + +function disposeCopyButton(host: HTMLElement) { + copyButtonState.get(host)?.dispose() + copyButtonState.delete(host) +} + +function disposeCopyButtons(root: Element) { + const hosts = [ + ...(root instanceof HTMLElement && root.getAttribute("data-slot") === "markdown-copy-button" ? [root] : []), + ...Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')).filter( + (el): el is HTMLElement => el instanceof HTMLElement, + ), + ] + hosts.forEach(disposeCopyButton) } const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"]) @@ -196,6 +226,7 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) { } for (const button of buttons.slice(1)) { + disposeCopyButton(button) button.remove() } } @@ -250,9 +281,9 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) { } function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { - const timeouts = new Map>() + const timeouts = new Map>() - const updateLabel = (button: HTMLButtonElement) => { + const updateLabel = (button: HTMLElement) => { const labels = getLabels() const copied = button.getAttribute("data-copied") === "true" setCopyState(button, labels, copied) @@ -263,7 +294,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { if (!(target instanceof Element)) return const button = target.closest('[data-slot="markdown-copy-button"]') - if (!(button instanceof HTMLButtonElement)) return + if (!(button instanceof HTMLElement)) return const code = button.closest('[data-component="markdown-code"]')?.querySelector("code") const content = code?.textContent ?? "" if (!content) return @@ -280,7 +311,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')) for (const button of buttons) { - if (button instanceof HTMLButtonElement) updateLabel(button) + if (button instanceof HTMLElement) updateLabel(button) } root.addEventListener("click", handleClick) @@ -290,6 +321,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { for (const timeout of timeouts.values()) { clearTimeout(timeout) } + disposeCopyButtons(root) } } @@ -431,6 +463,7 @@ export function Markdown( if (!container) return if (isServer) return if (content.length === 0) { + disposeCopyButtons(container) container.innerHTML = "" return } @@ -446,9 +479,14 @@ export function Markdown( activeCodeKeys.clear() nextCodeKeys.forEach((key) => activeCodeKeys.add(key)) content.forEach((block, index) => updateBlock(container, index, block, labels)) - while (container.children.length > content.length) container.lastElementChild?.remove() + while (container.children.length > content.length) { + const child = container.lastElementChild + if (!child) break + disposeCopyButtons(child) + child.remove() + } container - .querySelectorAll('[data-slot="markdown-copy-button"]') + .querySelectorAll('[data-slot="markdown-copy-button"]') .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true")) if (!copyCleanup) copyCleanup = setupCodeCopy(container, () => ({ @@ -538,8 +576,8 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl morphdom(current, next, { onBeforeElUpdated: (fromEl, toEl) => { if ( - fromEl instanceof HTMLButtonElement && - toEl instanceof HTMLButtonElement && + fromEl instanceof HTMLElement && + toEl instanceof HTMLElement && fromEl.getAttribute("data-slot") === "markdown-copy-button" && toEl.getAttribute("data-slot") === "markdown-copy-button" ) { @@ -548,6 +586,10 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl if (fromEl.isEqualNode(toEl)) return false return true }, + onBeforeNodeDiscarded: (node) => { + if (node instanceof Element) disposeCopyButtons(node) + return true + }, }) } @@ -616,8 +658,12 @@ function updateCodeBlock( unstable: block.unstable, raw: block.raw, }) - if (current) current.replaceWith(next) - else container.appendChild(next) + if (current) { + disposeCopyButtons(current) + current.replaceWith(next) + return + } + container.appendChild(next) } function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) { diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index baea5f91e719..88779a3c6c82 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -378,15 +378,6 @@ pointer-events: auto; } - [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] { - box-shadow: none; - border: 0.5px solid var(--v2-border-border-base); - } - - [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] { - color: var(--icon-base); - } - [data-slot="bash-scroll"] { width: 100%; overflow-y: auto; @@ -1364,10 +1355,6 @@ body:not([data-new-layout]) { [data-component="bash-output"] { border: 1px solid var(--border-weak-base); - - [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] { - border: 1px solid var(--border-weak-base); - } } [data-component="edit-trigger"], diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 17d3a697a7a7..67f037c9139a 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -50,6 +50,7 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/core/ut import { checksum } from "@opencode-ai/core/util/encode" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { Spinner } from "@opencode-ai/ui/spinner" @@ -196,6 +197,7 @@ function MessageActionButton( useV2?: boolean }, ) { + const icon = () => (props.icon === "copy" ? "outline-copy" : props.icon) return ( } + icon={} size="normal" variant="ghost-muted" disabled={props.disabled} @@ -1966,20 +1968,16 @@ ToolRegistry.register({ >
- - + } + size="normal" + variant="ghost-muted" onMouseDown={(e) => e.preventDefault()} onClick={handleCopy} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} /> - +
diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx
index 63e4ba0e457a..7e9cc6d7397d 100644
--- a/packages/ui/src/v2/components/icon.tsx
+++ b/packages/ui/src/v2/components/icon.tsx
@@ -101,6 +101,10 @@ const icons = {
     viewBox: "0 0 16 16",
     body: ``,
   },
+  reset: {
+    viewBox: "0 0 20 20",
+    body: ``,
+  },
   archive: {
     viewBox: "0 0 16 16",
     body: ``,