Files
claude-code-haha/desktop/src/components/composite/TargetIcon.tsx
T
程序员阿江(Relakkes) 98e1a12766 feat(desktop): redesign the desktop UI on the Porcelain visual system
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:51:00 +08:00

92 lines
2.9 KiB
TypeScript

import { useEffect, useState } from 'react'
import { AppWindow, Code2, ExternalLink, FolderOpen } from 'lucide-react'
import { apiGetBlob } from '../../api/client'
import type { OpenTarget } from '../../stores/openTargetStore'
export function getFallbackIcon(kind: OpenTarget['kind'], size = 16) {
if (kind === 'file_manager') {
return <FolderOpen size={size} strokeWidth={1.75} />
}
if (kind === 'application') return <AppWindow size={size} strokeWidth={1.75} />
if (kind === 'system_default') return <ExternalLink size={size} strokeWidth={1.75} />
return <Code2 size={size} strokeWidth={1.75} />
}
/**
* Blob URL per icon path, or `null` for "this bundle has no icon we can render".
*
* Cached for the life of the page, and deliberately never revoked: the same
* applications reappear in every menu, and a revoked URL would blank out any
* icon still mounted elsewhere. The payloads are a few KB of PNG.
*
* The misses are cached too. Without that, every menu render re-requests the
* bundles that have no icon, and each of those costs a `sips` run on the server.
*/
const iconUrlCache = new Map<string, string | null>()
const inFlight = new Map<string, Promise<string | null>>()
async function loadTargetIconUrl(iconPath: string): Promise<string | null> {
const cached = iconUrlCache.get(iconPath)
if (cached !== undefined) return cached
const pending = inFlight.get(iconPath) ?? (async () => {
try {
const blob = await apiGetBlob(iconPath)
const objectUrl = URL.createObjectURL(blob)
iconUrlCache.set(iconPath, objectUrl)
return objectUrl
} catch {
// A bundle with no icon, or a target the server no longer knows about.
// Either way the row renders its fallback glyph; nothing to report.
iconUrlCache.set(iconPath, null)
return null
} finally {
inFlight.delete(iconPath)
}
})()
inFlight.set(iconPath, pending)
return pending
}
/** Exposed for tests; the cache would otherwise leak between cases. */
export function resetTargetIconCache(): void {
iconUrlCache.clear()
inFlight.clear()
}
export function TargetIcon({ target, size = 18 }: { target: OpenTarget; size?: number }) {
const [objectUrl, setObjectUrl] = useState<string | null>(
() => (target.iconUrl ? iconUrlCache.get(target.iconUrl) ?? null : null),
)
const iconPath = target.iconUrl
useEffect(() => {
if (!iconPath) {
setObjectUrl(null)
return
}
let active = true
void loadTargetIconUrl(iconPath).then((url) => {
if (active) setObjectUrl(url)
})
return () => {
active = false
}
}, [iconPath])
if (objectUrl) {
return (
<img
src={objectUrl}
alt=""
aria-hidden="true"
draggable={false}
className="block shrink-0 object-contain"
style={{ width: size, height: size }}
/>
)
}
return getFallbackIcon(target.kind, Math.min(18, Math.max(14, size)))
}