Kaynağa Gözat

feat(app): more v2 ui alignment (#34465)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Aarav Sareen 1 ay önce
ebeveyn
işleme
246dea3a58

+ 28 - 37
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 { ProgressCircle } from "@opencode-ai/ui/progress-circle"
 import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
 import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 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 { useFile } from "@/context/file"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
@@ -18,7 +18,16 @@ import { createSessionTabs } from "@/pages/session/helpers"
 interface SessionContextUsageProps {
 interface SessionContextUsageProps {
   variant?: "button" | "indicator"
   variant?: "button" | "indicator"
   buttonAppearance?: "default" | "v2"
   buttonAppearance?: "default" | "v2"
-  placement?: TooltipProps["placement"]
+  placement?: ComponentProps<typeof TooltipV2>["placement"]
+}
+
+function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
+  return (
+    <div class="flex min-w-0 items-center gap-4">
+      <span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
+      <span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
+    </div>
+  )
 }
 }
 
 
 function openSessionContext(args: {
 function openSessionContext(args: {
@@ -113,38 +122,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   )
   )
 
 
   const tooltipValue = () => (
   const tooltipValue = () => (
-    <div>
-      <Show when={tokens()}>
-        {(value) => (
-          <div class="flex items-center gap-2">
-            <span class="text-text-invert-strong">
-              {getSessionTokenTotal(value())?.toLocaleString(language.intl())}
-            </span>
-            <span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
-          </div>
-        )}
-      </Show>
-      <Show when={context()}>
-        {(ctx) => (
-          <div class="flex items-center gap-2">
-            <span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
-            <span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
-          </div>
-        )}
-      </Show>
-      <div class="flex items-center gap-2">
-        <span class="text-text-invert-strong">{cost()}</span>
-        <span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
-      </div>
+    <div class="flex w-[120px] flex-col gap-2">
+      <ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
+      <ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
+      <ContextTooltipRow
+        name={language.t("context.usage.tokens")}
+        value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
+      />
     </div>
     </div>
   )
   )
 
 
   return (
   return (
     <Show when={params.id}>
     <Show when={params.id}>
-      <Switch>
-        <Match when={variant() === "indicator"}>{circle()}</Match>
-        <Match when={buttonAppearance() === "v2"}>
-          <Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
+      <TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
+        <Switch>
+          <Match when={variant() === "indicator"}>{circle()}</Match>
+          <Match when={buttonAppearance() === "v2"}>
             <IconButtonV2
             <IconButtonV2
               type="button"
               type="button"
               variant="ghost-muted"
               variant="ghost-muted"
@@ -153,10 +146,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
               onClick={openContext}
               onClick={openContext}
               aria-label={language.t("context.usage.view")}
               aria-label={language.t("context.usage.view")}
             />
             />
-          </Tooltip>
-        </Match>
-        <Match when={true}>
-          <Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
+          </Match>
+          <Match when={true}>
             <Button
             <Button
               type="button"
               type="button"
               variant="ghost"
               variant="ghost"
@@ -166,9 +157,9 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
             >
             >
               {circle()}
               {circle()}
             </Button>
             </Button>
-          </Tooltip>
-        </Match>
-      </Switch>
+          </Match>
+        </Switch>
+      </TooltipV2>
     </Show>
     </Show>
   )
   )
 }
 }

+ 5 - 60
packages/session-ui/src/components/markdown.css

@@ -192,65 +192,22 @@
     opacity: 0;
     opacity: 0;
     transition: opacity 0.15s ease;
     transition: opacity 0.15s ease;
     z-index: 1;
     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;
     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;
     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;
     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;
     display: inline-flex;
   }
   }
 
 
@@ -389,18 +346,6 @@ body:not([data-new-layout]) [data-component="markdown"] {
     background: none;
     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,
   th,
   td {
   td {
     border-bottom: 1px solid var(--border-weaker-base);
     border-bottom: 1px solid var(--border-weaker-base);

+ 95 - 49
packages/session-ui/src/components/markdown.tsx

@@ -3,16 +3,21 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
 import morphdom from "morphdom"
 import morphdom from "morphdom"
 import { checksum } from "@opencode-ai/core/util/encode"
 import { checksum } from "@opencode-ai/core/util/encode"
 import {
 import {
-  ComponentProps,
+  type Accessor,
+  type ComponentProps,
   createEffect,
   createEffect,
   createMemo,
   createMemo,
   createResource,
   createResource,
   createSignal,
   createSignal,
   createUniqueId,
   createUniqueId,
   onCleanup,
   onCleanup,
+  type Setter,
   splitProps,
   splitProps,
 } from "solid-js"
 } 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 { bundledLanguages } from "shiki"
 import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
 import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream"
 import {
 import {
@@ -48,11 +53,6 @@ type RenderResult = {
 
 
 const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
 const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
 
 
-const iconPaths = {
-  copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
-  check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
-}
-
 function escape(text: string) {
 function escape(text: string) {
   return text
   return text
     .replace(/&/g, "&amp;")
     .replace(/&/g, "&amp;")
@@ -87,6 +87,14 @@ type CopyLabels = {
   copied: string
   copied: string
 }
 }
 
 
+type CopyButtonState = {
+  setLabels: Setter<CopyLabels>
+  setCopied: Setter<boolean>
+  dispose: () => void
+}
+
+const copyButtonState = new WeakMap<HTMLElement, CopyButtonState>()
+
 const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
 const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
 
 
 function codeUrl(text: string) {
 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<CopyButtonState> = {}
+  const dispose = render(() => {
+    const [labelState, setLabels] = createSignal(labels, { equals: false })
+    const [copied, setCopied] = createSignal(false)
+    state.setLabels = setLabels
+    state.setCopied = setCopied
+    return <MarkdownCopyButton labels={labelState} copied={copied} />
+  }, 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<CopyLabels>; copied: Accessor<boolean> }) {
+  const label = () => (props.copied() ? props.labels().copied : props.labels().copy)
+  return (
+    <TooltipV2 placement="top" value={label()}>
+      <IconButtonV2
+        type="button"
+        size="normal"
+        variant="ghost-muted"
+        aria-label={label()}
+        icon={
+          <>
+            <IconV2 name="outline-copy" data-copy-icon />
+            <IconV2 name="check" data-check-icon />
+          </>
+        }
+      />
+    </TooltipV2>
+  )
 }
 }
 
 
-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) {
   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
     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"])
 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)) {
   for (const button of buttons.slice(1)) {
+    disposeCopyButton(button)
     button.remove()
     button.remove()
   }
   }
 }
 }
@@ -250,9 +281,9 @@ function decorate(root: HTMLDivElement, labels: CopyLabels) {
 }
 }
 
 
 function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
 function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
-  const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
+  const timeouts = new Map<HTMLElement, ReturnType<typeof setTimeout>>()
 
 
-  const updateLabel = (button: HTMLButtonElement) => {
+  const updateLabel = (button: HTMLElement) => {
     const labels = getLabels()
     const labels = getLabels()
     const copied = button.getAttribute("data-copied") === "true"
     const copied = button.getAttribute("data-copied") === "true"
     setCopyState(button, labels, copied)
     setCopyState(button, labels, copied)
@@ -263,7 +294,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
     if (!(target instanceof Element)) return
     if (!(target instanceof Element)) return
 
 
     const button = target.closest('[data-slot="markdown-copy-button"]')
     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 code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
     const content = code?.textContent ?? ""
     const content = code?.textContent ?? ""
     if (!content) return
     if (!content) return
@@ -280,7 +311,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
 
 
   const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
   const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
   for (const button of buttons) {
   for (const button of buttons) {
-    if (button instanceof HTMLButtonElement) updateLabel(button)
+    if (button instanceof HTMLElement) updateLabel(button)
   }
   }
 
 
   root.addEventListener("click", handleClick)
   root.addEventListener("click", handleClick)
@@ -290,6 +321,7 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
     for (const timeout of timeouts.values()) {
     for (const timeout of timeouts.values()) {
       clearTimeout(timeout)
       clearTimeout(timeout)
     }
     }
+    disposeCopyButtons(root)
   }
   }
 }
 }
 
 
@@ -431,6 +463,7 @@ export function Markdown(
     if (!container) return
     if (!container) return
     if (isServer) return
     if (isServer) return
     if (content.length === 0) {
     if (content.length === 0) {
+      disposeCopyButtons(container)
       container.innerHTML = ""
       container.innerHTML = ""
       return
       return
     }
     }
@@ -446,9 +479,14 @@ export function Markdown(
     activeCodeKeys.clear()
     activeCodeKeys.clear()
     nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
     nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
     content.forEach((block, index) => updateBlock(container, index, block, labels))
     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
     container
-      .querySelectorAll<HTMLButtonElement>('[data-slot="markdown-copy-button"]')
+      .querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
       .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
       .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
     if (!copyCleanup)
     if (!copyCleanup)
       copyCleanup = setupCodeCopy(container, () => ({
       copyCleanup = setupCodeCopy(container, () => ({
@@ -538,8 +576,8 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
   morphdom(current, next, {
   morphdom(current, next, {
     onBeforeElUpdated: (fromEl, toEl) => {
     onBeforeElUpdated: (fromEl, toEl) => {
       if (
       if (
-        fromEl instanceof HTMLButtonElement &&
-        toEl instanceof HTMLButtonElement &&
+        fromEl instanceof HTMLElement &&
+        toEl instanceof HTMLElement &&
         fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
         fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
         toEl.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
       if (fromEl.isEqualNode(toEl)) return false
       return true
       return true
     },
     },
+    onBeforeNodeDiscarded: (node) => {
+      if (node instanceof Element) disposeCopyButtons(node)
+      return true
+    },
   })
   })
 }
 }
 
 
@@ -616,8 +658,12 @@ function updateCodeBlock(
     unstable: block.unstable,
     unstable: block.unstable,
     raw: block.raw,
     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) {
 function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {

+ 0 - 13
packages/session-ui/src/components/message-part.css

@@ -378,15 +378,6 @@
     pointer-events: auto;
     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"] {
   [data-slot="bash-scroll"] {
     width: 100%;
     width: 100%;
     overflow-y: auto;
     overflow-y: auto;
@@ -1364,10 +1355,6 @@ body:not([data-new-layout]) {
 
 
   [data-component="bash-output"] {
   [data-component="bash-output"] {
     border: 1px solid var(--border-weak-base);
     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"],
   [data-component="edit-trigger"],

+ 9 - 11
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 { checksum } from "@opencode-ai/core/util/encode"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 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 { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Spinner } from "@opencode-ai/ui/spinner"
@@ -196,6 +197,7 @@ function MessageActionButton(
     useV2?: boolean
     useV2?: boolean
   },
   },
 ) {
 ) {
+  const icon = () => (props.icon === "copy" ? "outline-copy" : props.icon)
   return (
   return (
     <Show
     <Show
       when={props.useV2}
       when={props.useV2}
@@ -215,7 +217,7 @@ function MessageActionButton(
     >
     >
       <TooltipV2 value={props.label} placement="top" gutter={4}>
       <TooltipV2 value={props.label} placement="top" gutter={4}>
         <IconButtonV2
         <IconButtonV2
-          icon={<Icon name={props.icon} size="small" />}
+          icon={<IconV2 name={icon()} size="small" />}
           size="normal"
           size="normal"
           variant="ghost-muted"
           variant="ghost-muted"
           disabled={props.disabled}
           disabled={props.disabled}
@@ -1966,20 +1968,16 @@ ToolRegistry.register({
       >
       >
         <div data-component="bash-output">
         <div data-component="bash-output">
           <div data-slot="bash-copy">
           <div data-slot="bash-copy">
-            <Tooltip
-              value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
-              placement="top"
-              gutter={4}
-            >
-              <IconButton
-                icon={copied() ? "check" : "copy"}
-                size="small"
-                variant="secondary"
+            <TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
+              <IconButtonV2
+                icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
+                size="normal"
+                variant="ghost-muted"
                 onMouseDown={(e) => e.preventDefault()}
                 onMouseDown={(e) => e.preventDefault()}
                 onClick={handleCopy}
                 onClick={handleCopy}
                 aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
                 aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
               />
               />
-            </Tooltip>
+            </TooltipV2>
           </div>
           </div>
           <div data-slot="bash-scroll" data-scrollable>
           <div data-slot="bash-scroll" data-scrollable>
             <pre data-slot="bash-pre">
             <pre data-slot="bash-pre">

+ 4 - 0
packages/ui/src/v2/components/icon.tsx

@@ -105,6 +105,10 @@ const icons = {
     viewBox: "0 0 16 16",
     viewBox: "0 0 16 16",
     body: `<path d="M13.5555 6.66656V2.44434H9.33326M13.5555 2.44434L7.99993 7.99989M13.5555 9.33324V13.5555C13.5555 13.5555 12.7599 13.5555 11.7777 13.5555H2.44438C2.44438 13.5555 2.44438 12.7599 2.44438 11.7777V4.22213C2.44438 3.2399 2.44434 2.44435 2.44434 2.44435H6.66661" stroke="currentColor"/>`,
     body: `<path d="M13.5555 6.66656V2.44434H9.33326M13.5555 2.44434L7.99993 7.99989M13.5555 9.33324V13.5555C13.5555 13.5555 12.7599 13.5555 11.7777 13.5555H2.44438C2.44438 13.5555 2.44438 12.7599 2.44438 11.7777V4.22213C2.44438 3.2399 2.44434 2.44435 2.44434 2.44435H6.66661" stroke="currentColor"/>`,
   },
   },
+  reset: {
+    viewBox: "0 0 20 20",
+    body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
+  },
   archive: {
   archive: {
     viewBox: "0 0 16 16",
     viewBox: "0 0 16 16",
     body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44435L1.5557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.38901 5.55546L2.389 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.0557 2.44435L1.05566 5.55545L1.55566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 5.55546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,
     body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44435L1.5557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.38901 5.55546L2.389 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.0557 2.44435L1.05566 5.55545L1.55566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 5.55546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,