Files
claude-code-haha/desktop/src/components/chat/AssistantOutputTargetCard.tsx
T
程序员阿江-Relakkes 760ee4e481 feat(desktop): redesign the desktop UI on the Porcelain visual system (#1452)
Rebuild every desktop page on one visual system, 「素 Porcelain」: a quiet
light theme with a tool-call timeline, bubbled user messages and a floating
composer card.

- Tokens: rewrite the white theme on Porcelain values and give all six
  themes one radius scale (4/6/8/12/16/20), one type scale
  (11/12/13/14/15/18/22/26), Inter up to semibold, JetBrains Mono at 400,
  and status colours by meaning (running = info, waiting = warning,
  done = success, failed = error). Terracotta is kept for the mark, the
  send key and selection.
- Icons: draw every icon from lucide and drop the Material Symbols font.
  The generic sparkle glyph is gone: a working turn shows the running
  ring, skills use the same box the @ and / menus use for SKILL.md.
- Chat: tool calls read as a timeline, user turns as bubbles, the composer
  as a floating card; cards, menus, dialogs and permission prompts share
  the same surfaces and shadows.
- Shell: session tabs and workspace tabs use one shared tab chip; the
  sidebar, workspace and settings follow the same grounds.
- Settings: the page frame is fluid up to 1120px for every pane, so
  explorer-style panes (memory, skills) are no longer squeezed into 700px.
- Guard: designSystem.test.ts fails on off-scale font sizes, weights above
  semibold, italic text, Material Symbols, Tailwind radius names and
  sparkle icon imports.
2026-10-05 12:55:18 +08:00

127 lines
5.3 KiB
TypeScript

import { useCallback, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { ChevronDown, ExternalLink, Globe } from 'lucide-react'
import type { AssistantOutputTarget } from '../../lib/assistantOutputTargets'
import { useTranslation, type TranslationKey } from '../../i18n'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { OpenWithMenu } from '@/components/composite/OpenWithMenu'
import { FileTypeIcon } from '@/components/ui/FileTypeIcon'
import { describeFileType, type OpenWithItem } from '../../lib/openWithItems'
import { buildOpenWithMenuItemsForHref } from '../../lib/openWithMenuItems'
import { openPreviewLink } from '../../lib/openPreviewLink'
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; triggerEl: HTMLElement } | null>(null)
const isLocalhost = target.kind === 'localhost-url'
const typeInfo = describeFileType(target.normalizedPath ?? target.href)
const badge = isLocalhost
? t('assistantOutputs.kind.localhost')
: target.kind === 'local-html'
? t('assistantOutputs.kind.html')
: target.kind === 'markdown'
? t('assistantOutputs.kind.markdown')
: target.kind === 'file'
? t(typeInfo.categoryKey as TranslationKey)
: t('assistantOutputs.kind.image')
const subtitle = target.subtitle ?? target.normalizedPath ?? target.href
const showSubtitle = subtitle !== target.title
const handleOpen = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
openPreviewLink(target.href, sessionId)
}, [sessionId, target.href])
const handleOpenWith = useCallback((event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
// Toggle: a second click on the same trigger closes the menu. OpenWithMenu's
// outside-mousedown ignores the trigger, so the trigger's own click is the
// only path that can close it on re-click.
if (openWith) {
setOpenWith(null)
return
}
const triggerEl = event.currentTarget
const rect = triggerEl.getBoundingClientRect()
void (async () => {
const items = await buildOpenWithMenuItemsForHref(target.href, {
sessionId,
workDir,
t: (k, v) => t(k as TranslationKey, v),
})
if (items.length === 0) return
setOpenWith({ items, anchor: rect, triggerEl })
})()
}, [openWith, sessionId, t, target.href, workDir])
return (
<section className="flex items-stretch overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
{/* The whole row body is the hit area, not just the trailing icon button:
the icon is a discoverability affordance. Everything inside is a span
because a `button` may only contain phrasing content. */}
<button
type="button"
onClick={handleOpen}
aria-label={t('assistantOutputs.openAria', { title: target.title })}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
{isLocalhost ? (
<Globe size={15} strokeWidth={1.75} aria-hidden="true" />
) : (
<FileTypeIcon path={target.normalizedPath ?? target.href} size={16} />
)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{target.title}
</span>
<Badge>
{badge}
</Badge>
</span>
{showSubtitle && (
<span className="mt-0.5 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={subtitle}>
{subtitle}
</span>
)}
</span>
</button>
<div className="flex shrink-0 items-center gap-1 pr-2">
<IconButton
icon={<ExternalLink size={14} strokeWidth={1.75} aria-hidden="true" />}
label={t('assistantOutputs.open')}
size="sm"
tone="muted"
onClick={handleOpen}
/>
<Button
variant="secondary"
size="sm"
aria-label={t('openWith.title')}
onClick={handleOpenWith}
className="shrink-0"
icon={<ChevronDown size={12} strokeWidth={2} aria-hidden="true" />}
iconPosition="end"
>
{t('openWith.title')}
</Button>
</div>
{openWith && <OpenWithMenu items={openWith.items} anchor={openWith.anchor} triggerEl={openWith.triggerEl} onClose={() => setOpenWith(null)} />}
</section>
)
}