* fix(desktop): triage 24 GUI quality-of-life fixes across sidebar, composer, tool cards, messaging, and platform plumbing
A grab-bag of high-leverage UX fixes plus a few backend touches that the
GUI needs to behave correctly on Windows.
Sidebar / sessions
- Decrement $sessionsTotal on delete + archive so "Load N more" stops
claiming removed rows are still on the server.
- Hide the "Group by workspace" toggle when no unpinned sessions exist.
- Accept Cmd/Ctrl+N as a "new session" accelerator (in addition to bare
Shift+N), and render the kbd hint per-platform.
- Switch the statusbar to overflow-x-clip so untitled sessions don't
paint a horizontal scrollbar at the bottom of the window.
Messaging + Cron
- Add [-webkit-app-region: no-drag] to the page-search input so clicks
reach the field instead of routing to the OS window-drag handler.
- Replace single-letter PlatformAvatar with brand glyphs from
@icons-pack/react-simple-icons (telegram, discord, matrix, signal,
whatsapp, mattermost, wechat, qq, ...). Letter monogram fallback for
Slack / Dingtalk / Feishu / WeCom (removed from Simple Icons at brand
owner request).
- Drop the duplicate "Create first cron" button in the empty state.
Composer
- Dedupe pasted images by (name, size, lastModified, type) instead of
Blob identity; Chromium hands us the same screenshot via both
clipboard.items and clipboard.files with fresh File instances.
- Enable spellcheck on the contentEditable, configure Chromium's
spellchecker with the system locale on whenReady, and add
replaceMisspelling + "Add to dictionary" entries to the context menu.
- Render user messages through a minimal markdown pipeline (inline
backtick code + fenced ``` blocks) while keeping @file:/@image:
directive chips intact.
- max-h-[60vh] overflow-y-auto + collisionPadding on the prompt-snippet
submenu.
- Bake cursor-pointer into the <Button> primitive (with
disabled:cursor-default) and into titlebarButtonClass.
Dialogs + tabs + version
- Default DialogContent now has max-h-[85vh] overflow-y-auto so long
bodies scroll instead of falling off-screen.
- Right-rail preview tabs close on middle-click (button === 1), with an
onMouseDown swallow to suppress Chromium autoscroll.
- New refreshDesktopVersion() helper called from About mount, after
every update check, and on throttled window focus so About reflects
the just-installed binary.
Keys + Artifacts + Terminal
- Drop the global "Show advanced" toggle in KeysSettings. Provider
groups now default-expand when they have any key set.
- Extend openExternalUrl to handle file:// via shell.openPath, with
showItemInFolder fallback when the OS can't open the file.
- New lib/ansi.ts SGR parser + <AnsiText> component, applied to
terminal/execute_code tool output.
- ToolView gained stdout / stderr / rendersAnsi; tool-fallback renders
the two streams as separate labeled blocks with stderr in a neutral
tone (not destructive — many CLIs log info on stderr).
- Drop 'stderr' from ERROR_MSG_KEYS in tool-result-summary.
Paths + platform
- resolveHermesCwd skips process.cwd() when packaged and prefers a
user-configurable default project directory.
- New hermes:setting:defaultProjectDir:{get,set,pick} IPC handlers +
preload bridge + global.d.ts typing + a "Default project directory"
row in Sessions settings.
- FileOperations.delete_path(path, recursive=True) on the abstract
base; ShellFileOperations.delete_file rewritten to run a cross-
platform python3 -c snippet so deletes work on Windows shells (which
have no rm/rm -rf). Fallback to `python` when `python3` isn't on PATH.
- README troubleshooting block split into macOS/Linux + Windows
PowerShell recipes.
- Tightened renderer favicon links in index.html + added color-scheme
and theme-color meta.
Backend lifecycle (renderer-side mitigation)
- New noteSessionActivity() heartbeat + session.ts watchdog: an
8-minute silence on the stream auto-clears stuck $workingSessionIds
entries so "Session Busy" never gets permanently wedged. Wired into
useSessionStateCache so every state update refreshes the timer.
i18n spike
- docs/desktop-i18n-rfc.md scoping a future language-switcher PR
(recommends react-intl, audits IME/RTL/CJK in the composer +
chat bubbles, 4-PR rollout plan, ~3-4 eng-weeks for the first
non-English locale).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): replace native OS scrollbar in portaled dropdown menus
Radix's DropdownMenuPrimitive.Portal renders content under document.body,
outside the `.scrollbar-dt` scope on #root. Whenever a menu's max-height
clipped its content (even by a pixel — common for the composer "+" menu
that opens upward near the bottom of the window), the user saw the OS's
chunky native scrollbar painted across the whole menu.
Bake a thin, slot-styled scrollbar onto DropdownMenuContent and
DropdownMenuSubContent via [scrollbar-width:thin] + WebKit pseudo-element
arbitrary variants. The submenu also gets a max-h tied to
--radix-dropdown-menu-content-available-height so long snippet lists scroll
cleanly instead of running off the bottom of the viewport. Drop the now-
redundant max-h-[60vh] override on the prompt-snippet submenu.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): unbork dropdown menu — submenu opens, parent isn't a circle
Two regressions from the previous dropdown-scrollbar fix:
- The parent menu rendered as a rounded oval. Long Tailwind v4 arbitrary-
variant strings like [&::-webkit-scrollbar-thumb]:rounded-full inside a
cn() call were being mis-resolved so the `rounded-full` leaked onto the
menu container itself. Replaced the whole tower of arbitrary variants
with a real `.dt-portal-scrollbar` class in styles.css that mirrors what
`.scrollbar-dt` already does for #root descendants. Plain CSS, no Tailwind
parser ambiguity.
- The Prompt snippets submenu didn't open. Radix publishes
--radix-dropdown-menu-content-available-height on Content but NOT on
SubContent, so the `max-h` bound to that variable computed to 0 and the
submenu collapsed to zero height. Switched SubContent to a fixed
max-h-80 (≈20rem) which is plenty for a snippet list and never collapses.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): promote prompt snippets from Radix submenu to a real Dialog
The submenu refused to open when the parent dropdown was anchored at the
bottom of the window (composer "+" button) — Radix's collision detection +
SubContent positioning was fighting us. Rather than keep tuning side /
sideOffset / collisionPadding / max-h until something stuck, replace the
DropdownMenuSub with a clicked DropdownMenuItem that opens a proper
Dialog.
Side benefits over the submenu:
- Each snippet gets a description line, so a glance is enough to pick one.
- Focus management is handled by Dialog automatically.
- Easy to grow (search, custom user snippets, categories) without
another round of Radix positioning bugs.
Also extract types/interfaces to the bottom of the file per workspace
convention.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): move cron 'New cron' button off the top bar into the body
Reverses the previous direction on cron empty-state dedup. The body
button is more discoverable for first-time users (it's anchored next to
the "No scheduled jobs yet" copy that explains the feature) and frees
the top bar from a global CTA that wasn't pulling its weight.
- Empty (zero jobs): EmptyState renders the "Create first cron" button
again, like the original design.
- Empty (search filtered out all jobs): no button, just "Try a broader
search query" copy.
- Has jobs: small inline header above the list shows `N/M active` plus
a single "New cron" button (right-aligned). The rows themselves
already cover edit/pause/trigger/delete, so this is the only "create"
affordance.
Also drop the dead `<div className="hidden">…</div>` enabledCount line
the previous patch left behind; the count is now visible in the new
header instead of hidden.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): address Copilot review on PR 37536
- sessions-settings: guard the WHOLE bridge call rather than chaining
`?.settings.foo().then(...)` — the latter throws when
`window.hermesDesktop` is undefined (non-Electron / Vitest contexts)
because the chain short-circuits to `undefined.then(...)`.
- file_operations: drop `Path.unlink(missing_ok=True)` (Py>=3.8) so the
generated delete snippet still works on remote backends running
Python 3.7. The existing FileNotFoundError handler covers the same
case and works back to 3.4.
- ansi.test.ts: add focused Vitest coverage for the SGR parser
(basic/bright colors, bold toggles, default-fg reset, coalescing,
256-color / truecolor arg consumption, non-SGR CSI drop, empty SGR
full-reset) so future refactors can't silently regress terminal
rendering.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop/updates): swallow refreshDesktopVersion bridge errors
`refreshDesktopVersion()` is called best-effort with `void` from
`checkUpdates()`, `startUpdatePoller()`, and the window focus handler.
If the IPC bridge rejects (main process shutting down during reload,
bridge not yet ready on first paint), the rejection surfaces as an
unhandled promise rejection in the renderer. Wrap the call in try/catch
and return null on failure so callers can keep the existing
fire-and-forget pattern safely.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(desktop): drop work duplicated by other in-flight PRs
- composer/text-utils.ts: revert paste-image dedupe — PR #37596
ships the same fix with a cleaner content-key approach and a
Vitest file (text-utils.test.ts). Letting that PR own the change.
- docs/desktop-i18n-rfc.md: delete the i18n scoping RFC — PR #37568
has already shipped a working i18n surface (homegrown nanostores
`t()` helper over en/zh dictionaries), so the RFC's framework
recommendation (`react-intl`) is now obsolete and would just
contradict the implementation that's actually landing.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
548 lines
22 KiB
TypeScript
548 lines
22 KiB
TypeScript
'use client'
|
|
|
|
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
|
|
import { useStore } from '@nanostores/react'
|
|
import { createContext, type FC, type PropsWithChildren, type ReactNode, useContext, useMemo } from 'react'
|
|
import { useShallow } from 'zustand/shallow'
|
|
|
|
import { AnsiText } from '@/components/assistant-ui/ansi-text'
|
|
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
|
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
|
import { CompactMarkdown } from '@/components/chat/compact-markdown'
|
|
import { DiffLines } from '@/components/chat/diff-lines'
|
|
import { DisclosureRow } from '@/components/chat/disclosure-row'
|
|
import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
|
import { ZoomableImage } from '@/components/chat/zoomable-image'
|
|
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
|
import { Codicon } from '@/components/ui/codicon'
|
|
import { CopyButton } from '@/components/ui/copy-button'
|
|
import { FadeText } from '@/components/ui/fade-text'
|
|
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
|
|
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
|
|
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
|
import { cn } from '@/lib/utils'
|
|
import { $toolInlineDiffs } from '@/store/tool-diffs'
|
|
import { $toolDisclosureOpen, $toolViewMode, setToolDisclosureOpen } from '@/store/tool-view'
|
|
|
|
import {
|
|
groupCopyText as buildGroupCopyText,
|
|
buildToolView,
|
|
cleanVisibleText,
|
|
groupFailedStepCount,
|
|
groupPreviewTargets,
|
|
groupStatus,
|
|
groupTitle,
|
|
groupTotalDurationLabel,
|
|
inlineDiffFromResult,
|
|
isPreviewableTarget,
|
|
looksRedundant,
|
|
type SearchResultRow,
|
|
selectMessageRunning,
|
|
stripInlineDiffChrome,
|
|
toolCopyPayload,
|
|
type ToolPart,
|
|
toolPartDisclosureId,
|
|
type ToolStatus
|
|
} from './tool-fallback-model'
|
|
|
|
// Tool names that ChainToolFallback intercepts and renders as something
|
|
// other than a ToolEntry — they don't count toward "is this a group of
|
|
// tool calls?" because they have no visible tool block.
|
|
const SPECIAL_TOOL_NAMES = new Set(['todo', 'image_generate', 'clarify'])
|
|
|
|
// `true` when the current ToolEntry is being rendered inside a group
|
|
// wrapper. Lets ToolEntry suppress per-row chrome (timer / preview) that
|
|
// the group already shows.
|
|
const ToolEmbedContext = createContext(false)
|
|
|
|
// Shared header chrome for tool rows. Both the single-tool DisclosureRow
|
|
// and the multi-tool group header pass through these constants so a
|
|
// "Patch" row and a "Tool actions · 2 steps" row are visually identical.
|
|
const TOOL_HEADER_TITLE_CLASS =
|
|
'text-[length:var(--conversation-tool-font-size)] font-medium leading-(--conversation-line-height) text-(--ui-text-secondary)'
|
|
|
|
const TOOL_HEADER_DURATION_CLASS = 'shrink-0 text-[0.625rem] tabular-nums text-(--ui-text-tertiary)'
|
|
|
|
const TOOL_HEADER_SUBTITLE_CLASS =
|
|
'text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)'
|
|
|
|
const TOOL_HEADER_GLYPH_WRAP_CLASS = 'grid size-3.5 shrink-0 place-items-center self-center'
|
|
|
|
// Glass-style section label that sits above any pre/JSON/output block.
|
|
// Lowercase tracking + tiny size so it reads as a quiet field label rather
|
|
// than a chrome heading. Used for "COMMAND OUTPUT", "INPUT", "OUTPUT", etc.
|
|
const TOOL_SECTION_LABEL_CLASS = 'mb-1 text-[0.65rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)'
|
|
|
|
// Inset scroll surface for any detail body. The expanded tool row owns the
|
|
// border; the payload itself is just clipped raw text.
|
|
const TOOL_SECTION_SURFACE_CLASS =
|
|
'max-h-20 max-w-full overflow-auto bg-transparent px-2 py-1.5 text-(--ui-text-secondary)'
|
|
|
|
const TOOL_SECTION_PRE_CLASS = cn(TOOL_SECTION_SURFACE_CLASS, 'font-mono text-[0.7rem] leading-relaxed')
|
|
|
|
function rawTechnicalTrace(args: unknown, result: unknown): string {
|
|
const parts = [args, result]
|
|
.filter(value => value !== undefined && value !== null)
|
|
.map(value => {
|
|
if (typeof value === 'string') {
|
|
return value
|
|
}
|
|
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
|
|
return parts.join('\n')
|
|
}
|
|
|
|
function statusGlyph(status: ToolStatus): ReactNode {
|
|
if (status === 'running') {
|
|
return (
|
|
<BrailleSpinner
|
|
ariaLabel="Running"
|
|
className="size-3.5 shrink-0 text-[0.95rem] text-(--ui-text-tertiary)"
|
|
spinner="breathe"
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (status === 'error') {
|
|
return <AlertCircle aria-label="Error" className="size-3.5 shrink-0 text-destructive" />
|
|
}
|
|
|
|
if (status === 'warning') {
|
|
return <AlertCircle aria-label="Recovered" className="size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
}
|
|
|
|
return <CheckCircle2 aria-label="Done" className="size-3.5 shrink-0 text-emerald-600/85 dark:text-emerald-400/85" />
|
|
}
|
|
|
|
// Leading glyph for any tool-row header. Status (running/error/warning)
|
|
// takes precedence; otherwise falls back to the tool's codicon. Returns
|
|
// null when neither applies so callers can render unconditionally.
|
|
function ToolGlyph({ icon, status }: { icon?: string; status?: ToolStatus }) {
|
|
const node = status ? (
|
|
statusGlyph(status)
|
|
) : icon ? (
|
|
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
|
) : null
|
|
|
|
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
|
|
}
|
|
|
|
// Which status (if any) should pre-empt the tool's icon in the leading
|
|
// slot. Success is silent — the row reads as "done" without a checkmark.
|
|
function leadingStatus(isPending: boolean, status: ToolStatus): ToolStatus | undefined {
|
|
if (isPending) {
|
|
return 'running'
|
|
}
|
|
|
|
return status === 'success' ? undefined : status
|
|
}
|
|
|
|
function SearchResultsList({ hits }: { hits: SearchResultRow[] }) {
|
|
return (
|
|
<ol className="m-0 grid list-none gap-2.5 p-0">
|
|
{hits.map((hit, index) => {
|
|
const key = `${hit.url || hit.title}-${index}`
|
|
const trimmedTitle = hit.title.trim()
|
|
|
|
return (
|
|
<li className="grid min-w-0 gap-0.5" key={key}>
|
|
{hit.url ? (
|
|
<PrettyLink
|
|
className={cn(TOOL_HEADER_TITLE_CLASS, 'block max-w-full')}
|
|
fallbackLabel={trimmedTitle || urlSlugTitleLabel(hit.url)}
|
|
href={hit.url}
|
|
label={trimmedTitle || undefined}
|
|
/>
|
|
) : (
|
|
<span className={TOOL_HEADER_TITLE_CLASS}>{trimmedTitle}</span>
|
|
)}
|
|
{hit.snippet && <p className={cn(TOOL_HEADER_SUBTITLE_CLASS, 'm-0 line-clamp-3')}>{hit.snippet}</p>}
|
|
</li>
|
|
)
|
|
})}
|
|
</ol>
|
|
)
|
|
}
|
|
|
|
function LinkifiedText({ className, text }: { className?: string; text: string }) {
|
|
return <SharedLinkifiedText className={className} pretty text={cleanVisibleText(text)} />
|
|
}
|
|
|
|
interface ToolEntryProps {
|
|
part: ToolPart
|
|
}
|
|
|
|
function useDisclosureOpen(disclosureId: string, fallbackOpen = false): boolean {
|
|
const persistedOpen = useStore($toolDisclosureOpen(disclosureId))
|
|
|
|
return persistedOpen ?? fallbackOpen
|
|
}
|
|
|
|
function ToolEntry({ part }: ToolEntryProps) {
|
|
const messageId = useAuiState(s => s.message.id)
|
|
const messageRunning = useAuiState(selectMessageRunning)
|
|
const embedded = useContext(ToolEmbedContext)
|
|
const toolViewMode = useStore($toolViewMode)
|
|
const disclosureId = `tool-entry:${messageId}:${toolPartDisclosureId(part)}`
|
|
const open = useDisclosureOpen(disclosureId)
|
|
const isPending = messageRunning && part.result === undefined
|
|
// Only animate entries that mount while their message is actively
|
|
// streaming — historical sessions mount with `messageRunning === false`,
|
|
// so they paint statically without a settle cascade. The wrapping group
|
|
// handles its own enter animation, so embedded children skip it.
|
|
const enterRef = useEnterAnimation(messageRunning && !embedded, `tool-entry:${disclosureId}`)
|
|
const elapsed = useElapsedSeconds(isPending, `tool:${disclosureId}`)
|
|
const liveDiffs = useStore($toolInlineDiffs)
|
|
const sideDiff = part.toolCallId ? liveDiffs[part.toolCallId] || '' : ''
|
|
const inlineDiff = stripInlineDiffChrome(sideDiff) || inlineDiffFromResult(part.result)
|
|
|
|
// Stale parts (no result, but message stopped running) get a synthetic
|
|
// empty result so buildToolView treats them as completed-no-output.
|
|
const view = useMemo(() => {
|
|
const p = !isPending && part.result === undefined ? { ...part, result: {} } : part
|
|
|
|
return buildToolView(p, inlineDiff)
|
|
}, [inlineDiff, isPending, part])
|
|
|
|
const detailSections = useMemo(() => {
|
|
if (!view.detail) {
|
|
return { body: '', summary: '' }
|
|
}
|
|
|
|
if (view.status !== 'error') {
|
|
return { body: view.detail, summary: '' }
|
|
}
|
|
|
|
const chunks = view.detail
|
|
.split(/\n\s*\n+/)
|
|
.map(chunk => chunk.trim())
|
|
.filter(Boolean)
|
|
|
|
const [summary = '', ...rest] = chunks
|
|
const subtitleNorm = view.subtitle.trim().toLowerCase()
|
|
const summaryDuplicatesSubtitle = summary && summary.toLowerCase() === subtitleNorm
|
|
|
|
if (summaryDuplicatesSubtitle) {
|
|
return { body: rest.join('\n\n').trim(), summary: '' }
|
|
}
|
|
|
|
return { body: rest.join('\n\n').trim(), summary }
|
|
}, [view.detail, view.status, view.subtitle])
|
|
|
|
const detailMatchesSubtitle = looksRedundant(view.subtitle, view.detail)
|
|
|
|
const showDetail =
|
|
(view.status === 'error' && Boolean(detailSections.summary || detailSections.body)) ||
|
|
(view.status !== 'error' &&
|
|
Boolean(view.detail) &&
|
|
!looksRedundant(view.title, view.detail) &&
|
|
!detailMatchesSubtitle)
|
|
|
|
const renderDetailAsCode =
|
|
view.status !== 'error' &&
|
|
(part.toolName === 'terminal' || part.toolName === 'execute_code' || part.toolName === 'read_file')
|
|
|
|
const hasSearchHits = Boolean(view.searchHits?.length)
|
|
const searchResultsLabel = part.toolName === 'web_search' ? 'Search results' : view.detailLabel
|
|
|
|
const showRawSearchDrilldown =
|
|
part.toolName === 'web_search' &&
|
|
part.result !== undefined &&
|
|
toolViewMode !== 'technical' &&
|
|
Boolean(view.rawResult.trim())
|
|
|
|
const hasExpandableContent = Boolean(
|
|
(view.previewTarget && isPreviewableTarget(view.previewTarget)) ||
|
|
view.imageUrl ||
|
|
showDetail ||
|
|
hasSearchHits ||
|
|
toolViewMode === 'technical'
|
|
)
|
|
|
|
const copyAction = useMemo(() => toolCopyPayload(part, view), [part, view])
|
|
|
|
const trailing =
|
|
isPending && !embedded ? (
|
|
<ActivityTimerText className={TOOL_HEADER_DURATION_CLASS} seconds={elapsed} />
|
|
) : !isPending && copyAction.text ? (
|
|
<CopyButton appearance="tool-row" label={copyAction.label} stopPropagation text={copyAction.text} />
|
|
) : undefined
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'min-w-0 max-w-full overflow-hidden text-[length:var(--conversation-tool-font-size)] text-(--ui-text-tertiary)',
|
|
open && 'rounded-[0.625rem] border border-(--ui-stroke-tertiary)'
|
|
)}
|
|
data-slot="tool-block"
|
|
ref={enterRef}
|
|
>
|
|
<div className={cn(open && 'border-b border-(--ui-stroke-tertiary) px-2 py-1.5')}>
|
|
<DisclosureRow
|
|
onToggle={hasExpandableContent ? () => setToolDisclosureOpen(disclosureId, !open) : undefined}
|
|
open={open}
|
|
trailing={trailing}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
<ToolGlyph icon={view.icon} status={leadingStatus(isPending, view.status)} />
|
|
<FadeText
|
|
className={cn(
|
|
TOOL_HEADER_TITLE_CLASS,
|
|
isPending && 'shimmer text-(--ui-text-tertiary)',
|
|
view.status === 'error' && 'text-destructive',
|
|
view.status === 'warning' && 'text-amber-700 dark:text-amber-300'
|
|
)}
|
|
>
|
|
{view.title}
|
|
</FadeText>
|
|
{!isPending && view.countLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{view.countLabel}</span>}
|
|
{!isPending && view.durationLabel && (
|
|
<span className={TOOL_HEADER_DURATION_CLASS}>{view.durationLabel}</span>
|
|
)}
|
|
</span>
|
|
</DisclosureRow>
|
|
</div>
|
|
{open && (
|
|
<div className="grid w-full min-w-0 max-w-full gap-1.5 overflow-hidden p-1.5">
|
|
{!embedded && view.previewTarget && isPreviewableTarget(view.previewTarget) && (
|
|
<PreviewAttachment source="tool-result" target={view.previewTarget} />
|
|
)}
|
|
{view.imageUrl && (
|
|
<div className="max-w-72 overflow-hidden rounded-[0.25rem] border border-(--ui-stroke-tertiary)">
|
|
<ZoomableImage alt="Tool output" className="h-auto w-full object-cover" src={view.imageUrl} />
|
|
</div>
|
|
)}
|
|
{hasSearchHits && view.searchHits && (
|
|
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
|
{searchResultsLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{searchResultsLabel}</p>}
|
|
<SearchResultsList hits={view.searchHits} />
|
|
</div>
|
|
)}
|
|
{showDetail &&
|
|
toolViewMode !== 'technical' &&
|
|
(view.status === 'error' ? (
|
|
detailSections.summary || detailSections.body ? (
|
|
<div className="max-w-full text-xs leading-relaxed text-destructive">
|
|
{detailSections.summary && (
|
|
<LinkifiedText className="block font-medium" text={detailSections.summary} />
|
|
)}
|
|
{detailSections.body && (
|
|
<pre
|
|
className={cn(
|
|
'max-h-56 overflow-auto whitespace-pre-wrap wrap-anywhere font-mono text-[0.7rem] leading-[1.55] text-destructive/90',
|
|
detailSections.summary && 'mt-1.5'
|
|
)}
|
|
>
|
|
{detailSections.body}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
) : null
|
|
) : view.stdout || view.stderr ? (
|
|
// Stdout + stderr split: render both as labeled blocks. stderr
|
|
// is intentionally NOT painted destructive — many CLIs log
|
|
// informational output there.
|
|
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
|
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
|
{view.stdout && (
|
|
<div className="space-y-0.5">
|
|
{view.stderr && <p className={TOOL_SECTION_LABEL_CLASS}>stdout</p>}
|
|
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
|
{view.rendersAnsi ? <AnsiText text={view.stdout} /> : view.stdout}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
{view.stderr && (
|
|
<div className={cn('space-y-0.5', view.stdout && 'mt-1.5')}>
|
|
<p className={TOOL_SECTION_LABEL_CLASS}>stderr</p>
|
|
<pre
|
|
className={cn(
|
|
TOOL_SECTION_PRE_CLASS,
|
|
'whitespace-pre-wrap wrap-anywhere text-(--ui-text-tertiary)'
|
|
)}
|
|
>
|
|
{view.rendersAnsi ? <AnsiText text={view.stderr} /> : view.stderr}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="max-w-full text-xs leading-relaxed text-(--ui-text-secondary)">
|
|
{view.detailLabel && <p className={TOOL_SECTION_LABEL_CLASS}>{view.detailLabel}</p>}
|
|
{renderDetailAsCode ? (
|
|
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
|
{view.rendersAnsi ? <AnsiText text={view.detail} /> : view.detail}
|
|
</pre>
|
|
) : (
|
|
<CompactMarkdown className={cn(TOOL_SECTION_SURFACE_CLASS, 'wrap-anywhere')} text={view.detail} />
|
|
)}
|
|
</div>
|
|
))}
|
|
{showRawSearchDrilldown && (
|
|
<details className="max-w-full">
|
|
<summary className={cn(TOOL_SECTION_LABEL_CLASS, 'cursor-pointer mb-0')}>Raw response</summary>
|
|
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'mt-1 whitespace-pre-wrap wrap-anywhere')}>
|
|
{view.rawResult}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
{toolViewMode === 'technical' && (
|
|
<pre className={cn(TOOL_SECTION_PRE_CLASS, 'whitespace-pre-wrap wrap-anywhere')}>
|
|
{rawTechnicalTrace(part.args, part.result)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
{view.inlineDiff && <DiffLines text={view.inlineDiff} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Always-present wrapper around the consecutive tool-call range that
|
|
* `MessagePrimitive.Parts` already grouped for us. Renders a header +
|
|
* collapsible body when there are 2+ visible tools; otherwise it's a
|
|
* transparent passthrough that just owns the entry animation for the
|
|
* single ToolEntry inside.
|
|
*
|
|
* Crucially, the wrapper element is the SAME `<div>` regardless of
|
|
* group size — only the optional header element appears/disappears.
|
|
* That preserves React identity for the inner `MessagePartByIndex`
|
|
* children when the 1→2 transition happens, so existing tool blocks
|
|
* never remount when a new tool joins them mid-stream.
|
|
*
|
|
* The previous design (per-tool ToolFallback computing its own group
|
|
* lookup and conditionally returning either `<ToolEntry>` or
|
|
* `<ToolGroup>`) flipped the React element type at the 1→2 transition
|
|
* and tore down the existing tool entirely, which is what showed up as
|
|
* "the previous tool's animation resets every time a new tool arrives."
|
|
*/
|
|
export const ToolGroupSlot: FC<PropsWithChildren<{ endIndex: number; startIndex: number }>> = ({
|
|
children,
|
|
endIndex,
|
|
startIndex
|
|
}) => {
|
|
const messageId = useAuiState(s => s.message.id)
|
|
const messageRunning = useAuiState(selectMessageRunning)
|
|
|
|
// Pull the visible tool parts in this range. `useShallow` makes this
|
|
// re-render only when the actual part references change (assistant-ui
|
|
// gives stable refs for unchanged parts), not on every text/reasoning
|
|
// delta elsewhere in the message.
|
|
const visibleParts = useAuiState(
|
|
useShallow((s: { message: { parts: readonly unknown[] } }) =>
|
|
s.message.parts.slice(startIndex, endIndex + 1).filter((p): p is ToolPart => {
|
|
if (!p || typeof p !== 'object') {
|
|
return false
|
|
}
|
|
|
|
const row = p as { toolName?: unknown; type?: unknown }
|
|
|
|
return row.type === 'tool-call' && typeof row.toolName === 'string' && !SPECIAL_TOOL_NAMES.has(row.toolName)
|
|
})
|
|
)
|
|
)
|
|
|
|
const isGroup = visibleParts.length > 1
|
|
const isRunning = messageRunning && visibleParts.some(p => p.result === undefined)
|
|
// Stable across the group's lifetime (start index doesn't shift when
|
|
// tools append to the end), so user-driven open/close persists across
|
|
// streaming.
|
|
const disclosureId = `tool-group:${messageId}:${startIndex}`
|
|
const open = useDisclosureOpen(disclosureId)
|
|
const enterRef = useEnterAnimation(messageRunning, disclosureId)
|
|
|
|
const status = groupStatus(visibleParts)
|
|
const displayStatus = !isRunning && status === 'running' ? 'success' : status
|
|
const failedStepCount = useMemo(() => groupFailedStepCount(visibleParts), [visibleParts])
|
|
const totalDurationLabel = useMemo(() => groupTotalDurationLabel(visibleParts), [visibleParts])
|
|
|
|
const statusSummary =
|
|
displayStatus === 'running' || failedStepCount === 0
|
|
? ''
|
|
: displayStatus === 'warning'
|
|
? failedStepCount === 1
|
|
? 'Recovered after 1 failed step'
|
|
: `Recovered after ${failedStepCount} failed steps`
|
|
: failedStepCount === 1
|
|
? '1 step failed'
|
|
: `${failedStepCount} steps failed`
|
|
|
|
const groupCopyText = useMemo(() => buildGroupCopyText(visibleParts), [visibleParts])
|
|
const previewTargets = useMemo(() => groupPreviewTargets(visibleParts), [visibleParts])
|
|
|
|
return (
|
|
<ToolEmbedContext.Provider value={isGroup}>
|
|
<div className="min-w-0 max-w-full overflow-hidden" data-slot="tool-block" ref={enterRef}>
|
|
{isGroup && (
|
|
<DisclosureRow
|
|
key="header"
|
|
onToggle={() => setToolDisclosureOpen(disclosureId, !open)}
|
|
open={open}
|
|
trailing={
|
|
!isRunning && groupCopyText ? (
|
|
<CopyButton appearance="tool-row" label="Copy activity" stopPropagation text={groupCopyText} />
|
|
) : undefined
|
|
}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
<ToolGlyph status={displayStatus === 'success' ? undefined : displayStatus} />
|
|
<FadeText
|
|
className={cn(
|
|
TOOL_HEADER_TITLE_CLASS,
|
|
displayStatus === 'error' && 'text-destructive',
|
|
displayStatus === 'warning' && 'text-amber-700 dark:text-amber-300'
|
|
)}
|
|
>
|
|
{groupTitle(visibleParts)}
|
|
</FadeText>
|
|
{totalDurationLabel && <span className={TOOL_HEADER_DURATION_CLASS}>{totalDurationLabel}</span>}
|
|
</span>
|
|
{statusSummary && (
|
|
<FadeText
|
|
className={cn(
|
|
TOOL_HEADER_SUBTITLE_CLASS,
|
|
displayStatus === 'warning' ? 'text-amber-700/80 dark:text-amber-300/85' : 'text-destructive/85'
|
|
)}
|
|
>
|
|
{statusSummary}
|
|
</FadeText>
|
|
)}
|
|
</DisclosureRow>
|
|
)}
|
|
{isGroup && previewTargets.length > 0 && (
|
|
<div className="mt-2 grid w-full min-w-0 max-w-full gap-2 overflow-hidden pr-2 pl-3">
|
|
{previewTargets.map(target => (
|
|
<PreviewAttachment key={target} source="tool-result" target={target} />
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* Body is always rendered so children stay mounted across collapse/
|
|
expand and across the 1→2 group transition. `hidden` removes it
|
|
from a11y/visual flow without unmounting React subtree. */}
|
|
<div className={cn(isGroup && 'mt-0.5 w-full overflow-hidden pr-2 pl-3')} hidden={isGroup && !open} key="body">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</ToolEmbedContext.Provider>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Per-tool fallback. Now strictly returns a single ToolEntry — the
|
|
* grouping decision lives in ToolGroupSlot above, so this never swaps
|
|
* its return type and the underlying ToolEntry stays mounted across
|
|
* group-shape changes.
|
|
*/
|
|
export const ToolFallback = ({ toolCallId, toolName, args, isError, result }: ToolCallMessagePartProps) => {
|
|
const part: ToolPart = { args, isError, result, toolCallId, toolName, type: 'tool-call' }
|
|
|
|
return <ToolEntry part={part} />
|
|
}
|