feat(desktop): render message-complete previewable refs as rich cards (drop inline pill)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 17:04:56 +08:00
parent e49b9390b6
commit 95d1ba997a
6 changed files with 344 additions and 170 deletions

View File

@ -1,5 +1,5 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { openBrowser } = vi.hoisted(() => ({ openBrowser: vi.fn() }))
@ -11,7 +11,7 @@ vi.mock('../../lib/desktopRuntime', async (orig) => ({
getServerBaseUrl: () => 'http://127.0.0.1:4321',
}))
// Mock openTargetStore for the open-with menu
// Mock openTargetStore for the open-with menu (used by the cards)
const ensureTargets = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const openTargetFn = vi.hoisted(() => vi.fn())
vi.mock('../../stores/openTargetStore', () => ({
@ -20,29 +20,28 @@ vi.mock('../../stores/openTargetStore', () => ({
},
}))
// Mock workspacePanelStore — workDir returns undefined (no active workspace)
// Mock workspacePanelStore — usable both as a hook selector and via getState().
// workDir is undefined (no active workspace) so relative paths resolve as-is.
const openPreviewFn = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('../../stores/workspacePanelStore', () => ({
useWorkspacePanelStore: {
getState: () => ({
statusBySession: {},
openPreview: openPreviewFn,
}),
},
}))
vi.mock('../../stores/workspacePanelStore', () => {
const state = { statusBySession: {} as Record<string, { workDir?: string } | undefined>, openPreview: openPreviewFn }
const useWorkspacePanelStore = Object.assign(
(selector: (s: typeof state) => unknown) => selector(state),
{ getState: () => state },
)
return { useWorkspacePanelStore }
})
// Mock tauri shell (used by openSystem inside handleContentClick)
// Mock tauri shell (used by openSystem inside the card's open-with)
const shellOpen = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('@tauri-apps/plugin-shell', () => ({ open: shellOpen }))
// Mock i18n — return the key as the label so we can assert on keys
vi.mock('../../i18n', () => ({
useTranslation: () => (k: string, v?: Record<string, string>) => (v?.target ? `${k}:${v.target}` : k),
// TranslationKey is just a string-branded type; no runtime value needed
}))
// Mock settingsStore (pulled in transitively by i18n when NOT mocking i18n at module level)
// (already covered by the i18n mock above, but add a safety net)
// Mock settingsStore (safety net for transitive i18n usage)
vi.mock('../../stores/settingsStore', () => ({
useSettingsStore: Object.assign((sel: (s: { locale: string }) => unknown) => sel({ locale: 'en' }), {
getState: () => ({ locale: 'en' }),
@ -56,120 +55,74 @@ afterEach(() => {
openBrowser.mockReset()
ensureTargets.mockReset().mockResolvedValue(undefined)
openTargetFn.mockReset()
openPreviewFn.mockReset().mockResolvedValue(undefined)
})
describe('AssistantMessage link routing', () => {
it('opens a localhost link in the in-app browser', () => {
it('opens a localhost link in the in-app browser on left-click', () => {
render(<AssistantMessage sessionId="s1" content={'打开 [预览](http://localhost:5173/)'} />)
fireEvent.click(screen.getByText('预览'))
// The clickable element is the rendered markdown anchor (the card title is a span).
fireEvent.click(screen.getByRole('link', { name: '预览' }))
expect(openBrowser).toHaveBeenCalledWith('s1', 'http://localhost:5173/')
})
})
describe('AssistantMessage open-with trigger injection', () => {
it('injects a ▾ trigger for a previewable link after streaming ends', () => {
describe('AssistantMessage output-target cards', () => {
it('renders a card for a localhost URL after streaming ends', () => {
render(
<AssistantMessage
sessionId="s1"
content={'打开 [预览](http://localhost:5173/)'}
content={'本地服务运行在 http://localhost:5173/ 上'}
isStreaming={false}
/>,
)
expect(screen.getByLabelText('打开方式')).toBeInTheDocument()
expect(screen.getAllByText('http://localhost:5173/').length).toBeGreaterThan(0)
expect(screen.getByText('assistantOutputs.kind.localhost')).toBeInTheDocument()
})
it('does NOT inject a trigger while streaming', () => {
it('renders a card for a markdown link with its Markdown badge', () => {
render(
<AssistantMessage
sessionId="s1"
content={'打开 [预览](http://localhost:5173/)'}
content={'见 [说明文档](docs/readme.md)'}
isStreaming={false}
/>,
)
// Link text appears in both the bubble anchor and the card title; the badge is unique.
expect(screen.getByRole('link', { name: '说明文档' })).toBeInTheDocument()
expect(screen.getByText('assistantOutputs.kind.markdown')).toBeInTheDocument()
})
it('does NOT render cards while streaming', () => {
render(
<AssistantMessage
sessionId="s1"
content={'本地服务运行在 http://localhost:5173/ 上'}
isStreaming={true}
/>,
)
expect(screen.queryByLabelText('打开方式')).toBeNull()
expect(screen.queryByText('assistantOutputs.kind.localhost')).toBeNull()
})
it('does NOT inject a trigger for an ignored link (#anchor)', () => {
it('does NOT render cards when there are no previewable references', () => {
render(
<AssistantMessage
sessionId="s1"
content={'[anchor](#section)'}
content={'装一下 `npm install` 然后看 [anchor](#section)'}
isStreaming={false}
/>,
)
expect(screen.queryByLabelText('打开方式')).toBeNull()
expect(screen.queryByText('assistantOutputs.kind.localhost')).toBeNull()
expect(screen.queryByText('Markdown')).toBeNull()
})
it('does NOT inject a trigger when sessionId is absent', () => {
it('does NOT render cards when sessionId is absent', () => {
render(
<AssistantMessage
content={'打开 [预览](http://localhost:5173/)'}
content={'本地服务运行在 http://localhost:5173/ 上'}
isStreaming={false}
/>,
)
expect(screen.queryByLabelText('打开方式')).toBeNull()
})
it('clicking the ▾ trigger opens a menu with in-app-browser and system-browser items for a URL', async () => {
render(
<AssistantMessage
sessionId="s1"
content={'打开 [预览](http://localhost:5173/)'}
isStreaming={false}
/>,
)
const trigger = screen.getByLabelText('打开方式')
fireEvent.click(trigger)
// Wait for the async ensureTargets chain to resolve and the menu to appear
await waitFor(() => {
// buildOpenWithItems for kind:'url' produces in-app + system keys
expect(screen.getByText('openWith.inAppBrowser')).toBeInTheDocument()
})
expect(screen.getByText('openWith.systemBrowser')).toBeInTheDocument()
})
})
describe('AssistantMessage open-with trigger injection — inline code URLs', () => {
it('injects a ▾ trigger next to an inline-code localhost URL', async () => {
render(
<AssistantMessage
sessionId="s1"
content={'运行地址 `http://localhost:9527/`'}
isStreaming={false}
/>,
)
// The trigger comes from the <code> path (no <a> in this content)
expect(screen.getByLabelText('打开方式')).toBeInTheDocument()
})
it('clicking the trigger next to inline-code URL opens menu with in-app-browser and system-browser items', async () => {
render(
<AssistantMessage
sessionId="s1"
content={'运行地址 `http://localhost:9527/`'}
isStreaming={false}
/>,
)
const trigger = screen.getByLabelText('打开方式')
fireEvent.click(trigger)
await waitFor(() => {
expect(screen.getByText('openWith.inAppBrowser')).toBeInTheDocument()
})
expect(screen.getByText('openWith.systemBrowser')).toBeInTheDocument()
})
it('does NOT inject a trigger for non-URL inline code', () => {
render(
<AssistantMessage
sessionId="s1"
content={'装一下 `npm install`'}
isStreaming={false}
/>,
)
expect(screen.queryByLabelText('打开方式')).toBeNull()
expect(screen.queryByText('assistantOutputs.kind.localhost')).toBeNull()
})
})

View File

@ -1,18 +1,15 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useMemo } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
import { InlineImageGallery } from './InlineImageGallery'
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
import { handlePreviewLink } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { OpenWithMenu } from '../common/OpenWithMenu'
import { buildOpenWithItems, type OpenWithItem } from '../../lib/openWithItems'
import { openWithContextForHref } from '../../lib/openWithContextForHref'
import { classifyPreviewLink } from '../../lib/previewLinkRouter'
import { useTranslation, type TranslationKey } from '../../i18n'
import { useTranslation } from '../../i18n'
type Props = {
content: string
@ -21,10 +18,11 @@ type Props = {
sessionId?: string
}
const MAX_CARDS = 3
export const AssistantMessage = memo(function AssistantMessage({ content, isStreaming, branchAction, sessionId }: Props) {
const t = useTranslation()
const contentRef = useRef<HTMLDivElement>(null)
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect } | null>(null)
const workDir = useWorkspacePanelStore((s) => (sessionId ? s.statusBySession[sessionId]?.workDir : undefined))
const handleLinkClick = useCallback(
(href: string, event: ReactMouseEvent<HTMLDivElement>): boolean => {
@ -48,65 +46,10 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
[sessionId],
)
// Inject ▾ triggers after streaming completes — gated on !isStreaming
useEffect(() => {
const root = contentRef.current
if (!root || !sessionId || isStreaming) return
function makeTrigger(href: string): HTMLButtonElement {
const trigger = document.createElement('button')
trigger.type = 'button'
trigger.className = 'md-open-with'
trigger.dataset.openWithHref = href
trigger.setAttribute('aria-label', '打开方式')
trigger.tabIndex = -1
trigger.textContent = '打开方式 ⌄'
// A clear, properly-sized pill (not a tiny bare caret): bigger hit target + discoverable.
trigger.style.cssText = 'display:inline-flex;align-items:center;margin:0 3px;padding:1px 8px;border:1px solid var(--color-border);border-radius:9px;background:var(--color-surface);color:var(--color-text-secondary);cursor:pointer;font-size:11px;line-height:1.5;vertical-align:middle;white-space:nowrap'
return trigger
}
root.querySelectorAll<HTMLAnchorElement>('a[href]').forEach((link) => {
const href = link.getAttribute('href') ?? ''
if (classifyPreviewLink(href).kind === 'ignored') return
if (link.nextElementSibling instanceof HTMLElement && link.nextElementSibling.classList.contains('md-open-with')) return
link.after(makeTrigger(href))
})
root.querySelectorAll<HTMLElement>('code').forEach((code) => {
if (code.closest('pre')) return
const text = (code.textContent ?? '').trim()
const kind = classifyPreviewLink(text).kind
if (kind !== 'browser-localhost' && kind !== 'remote') return
if (code.nextElementSibling instanceof HTMLElement && code.nextElementSibling.classList.contains('md-open-with')) return
code.after(makeTrigger(text))
})
}, [content, isStreaming, sessionId])
const handleContentClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
const trigger = (event.target as HTMLElement | null)?.closest<HTMLElement>('.md-open-with')
if (!trigger || !sessionId) return
event.preventDefault()
event.stopPropagation()
const href = trigger.dataset.openWithHref ?? ''
const rect = trigger.getBoundingClientRect()
void (async () => {
const store = useOpenTargetStore.getState()
await store.ensureTargets()
const targets = useOpenTargetStore.getState().targets
const workDir = useWorkspacePanelStore.getState().statusBySession[sessionId]?.workDir
const ctx = openWithContextForHref(href, { sessionId, serverBaseUrl: getServerBaseUrl(), workDir })
if (!ctx) return
const items = buildOpenWithItems(ctx, targets, {
openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url),
openSystem: (p) => { void import('@tauri-apps/plugin-shell').then((m) => m.open(p)).catch(() => window.open(p, '_blank')) },
openWorkspacePreview: (relPath) => { void useWorkspacePanelStore.getState().openPreview(sessionId, relPath, 'file') },
openTarget: (id, abs) => { void useOpenTargetStore.getState().openTarget(id, abs) },
t: (key, vars) => t(key as TranslationKey, vars),
})
setOpenWith({ items, anchor: rect })
})()
}, [sessionId, t])
const outputTargets = useMemo(
() => (isStreaming || !sessionId ? [] : extractAssistantOutputTargets(content, { workDir })),
[content, isStreaming, sessionId, workDir],
)
if (!content.trim()) return null
@ -126,20 +69,31 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
<div className={`rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm ${
documentLayout ? 'w-full' : 'max-w-full'
}`}>
<div ref={contentRef} onClick={handleContentClick}>
<MarkdownRenderer
content={content}
variant={documentLayout ? 'document' : 'default'}
streaming={isStreaming}
onLinkClick={sessionId ? handleLinkClick : undefined}
/>
</div>
<MarkdownRenderer
content={content}
variant={documentLayout ? 'document' : 'default'}
streaming={isStreaming}
onLinkClick={sessionId ? handleLinkClick : undefined}
/>
{!isStreaming && <InlineImageGallery text={content} />}
{isStreaming && (
<span className="ml-0.5 inline-block h-4 w-0.5 animate-shimmer bg-[var(--color-brand)] align-text-bottom" />
)}
</div>
{!isStreaming && sessionId && outputTargets.length > 0 && (
<div className="mt-1 flex w-full flex-col gap-2">
{outputTargets.slice(0, MAX_CARDS).map((target) => (
<AssistantOutputTargetCard key={target.id} target={target} sessionId={sessionId} workDir={workDir} />
))}
{outputTargets.length > MAX_CARDS && (
<div className="px-1 text-xs text-[var(--color-text-tertiary)]">
{t('assistantOutputs.moreOutputs', { count: String(outputTargets.length - MAX_CARDS) })}
</div>
)}
</div>
)}
<MessageActionBar
copyText={isStreaming ? undefined : content}
copyLabel="Copy reply"
@ -147,7 +101,6 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
align="start"
/>
</div>
{openWith && <OpenWithMenu items={openWith.items} anchor={openWith.anchor} onClose={() => setOpenWith(null)} />}
</div>
)
})

View File

@ -0,0 +1,110 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const { openBrowser } = vi.hoisted(() => ({ openBrowser: vi.fn() }))
vi.mock('../../stores/browserPanelStore', () => ({
useBrowserPanelStore: { getState: () => ({ open: openBrowser }) },
}))
vi.mock('../../lib/desktopRuntime', async (orig) => ({
...(await orig<Record<string, unknown>>()),
getServerBaseUrl: () => 'http://127.0.0.1:4321',
}))
const ensureTargets = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
const openTargetFn = vi.hoisted(() => vi.fn())
vi.mock('../../stores/openTargetStore', () => ({
useOpenTargetStore: {
getState: () => ({ ensureTargets, targets: [], openTarget: openTargetFn }),
},
}))
const openPreviewFn = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('../../stores/workspacePanelStore', () => ({
useWorkspacePanelStore: {
getState: () => ({ statusBySession: {}, openPreview: openPreviewFn }),
},
}))
const shellOpen = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
vi.mock('@tauri-apps/plugin-shell', () => ({ open: shellOpen }))
// Mock i18n — return the key (plus interpolation) so we can assert on keys
vi.mock('../../i18n', () => ({
useTranslation: () => (k: string, v?: Record<string, string>) => (v?.target ? `${k}:${v.target}` : k),
}))
import { AssistantOutputTargetCard } from './AssistantOutputTargetCard'
import type { AssistantOutputTarget } from '../../lib/assistantOutputTargets'
const markdownTarget: AssistantOutputTarget = {
id: 'markdown:docs/readme.md',
kind: 'markdown',
title: 'readme.md',
subtitle: 'docs/readme.md',
href: 'docs/readme.md',
normalizedPath: 'docs/readme.md',
confidence: 'high',
source: 'markdown-link',
}
const localhostTarget: AssistantOutputTarget = {
id: 'localhost-url:http://localhost:5173/',
kind: 'localhost-url',
title: 'http://localhost:5173/',
href: 'http://localhost:5173/',
confidence: 'high',
source: 'plain-url',
}
afterEach(() => {
openBrowser.mockReset()
ensureTargets.mockReset().mockResolvedValue(undefined)
openTargetFn.mockReset()
openPreviewFn.mockReset().mockResolvedValue(undefined)
shellOpen.mockReset().mockResolvedValue(undefined)
})
describe('AssistantOutputTargetCard', () => {
it('renders a markdown target title + Markdown badge', () => {
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
expect(screen.getByText('readme.md')).toBeInTheDocument()
expect(screen.getByText('assistantOutputs.kind.markdown')).toBeInTheDocument()
expect(screen.getByText('docs/readme.md')).toBeInTheDocument()
})
it('renders a localhost target title + Localhost badge', () => {
render(<AssistantOutputTargetCard target={localhostTarget} sessionId="s1" />)
// Title + subtitle both fall back to href, so it appears twice.
expect(screen.getAllByText('http://localhost:5173/').length).toBeGreaterThan(0)
expect(screen.getByText('assistantOutputs.kind.localhost')).toBeInTheDocument()
})
it('routes Open to workspace preview for a markdown target', () => {
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
fireEvent.click(screen.getByLabelText('assistantOutputs.open'))
expect(openPreviewFn).toHaveBeenCalledWith('s1', 'docs/readme.md', 'file')
})
it('routes Open to the in-app browser for a localhost target', () => {
render(<AssistantOutputTargetCard target={localhostTarget} sessionId="s1" />)
fireEvent.click(screen.getByLabelText('assistantOutputs.open'))
expect(openBrowser).toHaveBeenCalledWith('s1', 'http://localhost:5173/')
})
it('copies the normalized path when Copy is clicked', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText } })
render(<AssistantOutputTargetCard target={markdownTarget} sessionId="s1" />)
fireEvent.click(screen.getByLabelText('assistantOutputs.copy'))
expect(writeText).toHaveBeenCalledWith('docs/readme.md')
})
it('opens the open-with menu with URL items for a localhost target', async () => {
render(<AssistantOutputTargetCard target={localhostTarget} sessionId="s1" />)
fireEvent.click(screen.getByLabelText('openWith.title'))
expect(await screen.findByText('openWith.inAppBrowser')).toBeInTheDocument()
expect(screen.getByText('openWith.systemBrowser')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,140 @@
import { useCallback, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { ChevronDown, Copy, ExternalLink, Globe } from 'lucide-react'
import type { AssistantOutputTarget } from '../../lib/assistantOutputTargets'
import { useTranslation, type TranslationKey } from '../../i18n'
import { OpenWithMenu } from '../common/OpenWithMenu'
import { buildOpenWithItems, describeFileType, type OpenWithItem } from '../../lib/openWithItems'
import { openWithContextForHref } from '../../lib/openWithContextForHref'
import { handlePreviewLink } from '../../lib/handlePreviewLink'
import { getServerBaseUrl } from '../../lib/desktopRuntime'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { copyTextToClipboard } from './clipboard'
type Props = {
target: AssistantOutputTarget
sessionId: string
workDir?: string
}
export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props) {
const t = useTranslation()
const [openWith, setOpenWith] = useState<{ items: OpenWithItem[]; anchor: DOMRect } | null>(null)
const isLocalhost = target.kind === 'localhost-url'
const typeInfo = describeFileType(target.normalizedPath ?? target.href)
const icon = typeInfo.icon
const badge = isLocalhost
? t('assistantOutputs.kind.localhost')
: target.kind === 'local-html'
? t('assistantOutputs.kind.html')
: target.kind === 'markdown'
? t('assistantOutputs.kind.markdown')
: t('assistantOutputs.kind.image')
const subtitle = target.subtitle ?? target.normalizedPath ?? target.href
const handleOpen = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
handlePreviewLink(target.href, {
sessionId,
serverBaseUrl: getServerBaseUrl(),
openBrowser: (id, url) => useBrowserPanelStore.getState().open(id, url),
openFilePreview: (id, path) => {
void useWorkspacePanelStore.getState().openPreview(id, path, 'file')
},
openExternal: (url) => {
void import('@tauri-apps/plugin-shell')
.then((m) => m.open(url))
.catch(() => window.open(url, '_blank'))
},
})
}, [sessionId, target.href])
const handleCopy = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
void copyTextToClipboard(target.normalizedPath ?? target.href)
}, [target.href, target.normalizedPath])
const handleOpenWith = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
void (async () => {
await useOpenTargetStore.getState().ensureTargets()
const targets = useOpenTargetStore.getState().targets
const ctx = openWithContextForHref(target.href, {
sessionId,
serverBaseUrl: getServerBaseUrl(),
workDir,
})
if (!ctx) return
const items = buildOpenWithItems(ctx, targets, {
openInAppBrowser: (url) => useBrowserPanelStore.getState().open(sessionId, url),
openSystem: (p) => { void import('@tauri-apps/plugin-shell').then((m) => m.open(p)).catch(() => window.open(p, '_blank')) },
openWorkspacePreview: (relPath) => { void useWorkspacePanelStore.getState().openPreview(sessionId, relPath, 'file') },
openTarget: (id, abs) => { void useOpenTargetStore.getState().openTarget(id, abs) },
t: (k, v) => t(k as TranslationKey, v),
})
setOpenWith({ items, anchor: rect })
})()
}, [sessionId, t, target.href, workDir])
return (
<section className="flex items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-3 py-2.5 shadow-sm">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
{isLocalhost ? (
<Globe size={17} strokeWidth={2.1} aria-hidden="true" />
) : (
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">{icon}</span>
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{target.title}
</span>
<span className="shrink-0 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{badge}
</span>
</div>
<div className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]" title={subtitle}>
{subtitle}
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={handleOpen}
aria-label={t('assistantOutputs.open')}
title={t('assistantOutputs.open')}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<ExternalLink size={14} strokeWidth={2.2} aria-hidden="true" />
</button>
<button
type="button"
onClick={handleCopy}
aria-label={t('assistantOutputs.copy')}
title={t('assistantOutputs.copy')}
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<Copy size={14} strokeWidth={2.2} aria-hidden="true" />
</button>
<button
type="button"
aria-label={t('openWith.title')}
onClick={handleOpenWith}
className="inline-flex h-8 shrink-0 items-center gap-1 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-brand)]/35 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
{t('openWith.title')}
<ChevronDown size={13} strokeWidth={2.2} aria-hidden="true" />
</button>
</div>
{openWith && <OpenWithMenu items={openWith.items} anchor={openWith.anchor} onClose={() => setOpenWith(null)} />}
</section>
)
}

View File

@ -105,6 +105,15 @@ export const en = {
'openWith.fileType.code': 'Code',
'openWith.fileType.file': 'File',
// ─── Assistant Output Targets ──────────────────────
'assistantOutputs.kind.markdown': 'Markdown',
'assistantOutputs.kind.html': 'HTML',
'assistantOutputs.kind.image': 'Image',
'assistantOutputs.kind.localhost': 'Localhost',
'assistantOutputs.moreOutputs': '+{count} more',
'assistantOutputs.open': 'Open',
'assistantOutputs.copy': 'Copy path',
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': 'Changed files',
'workspace.allFiles': 'All files',

View File

@ -107,6 +107,15 @@ export const zh: Record<TranslationKey, string> = {
'openWith.fileType.code': '代码',
'openWith.fileType.file': '文件',
// ─── Assistant Output Targets ──────────────────────
'assistantOutputs.kind.markdown': 'Markdown',
'assistantOutputs.kind.html': 'HTML',
'assistantOutputs.kind.image': '图片',
'assistantOutputs.kind.localhost': '本地服务',
'assistantOutputs.moreOutputs': '+{count} 个输出',
'assistantOutputs.open': '打开',
'assistantOutputs.copy': '复制路径',
// ─── Workspace Panel ───────────────────────────────
'workspace.changedFiles': '已更改文件',
'workspace.allFiles': '所有文件',