mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
760ee4e481
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.
127 lines
5.3 KiB
TypeScript
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>
|
|
)
|
|
}
|