Files
tolaria/src/components/AiActionCard.tsx
Test 2746fb88ad fix: replace monospaced ALL CAPS labels with Inter sentence case
Remove IBM Plex Mono + text-transform: uppercase from all UI labels.
CSS classes .font-mono-label and .font-mono-overline now use Inter
with no text-transform. Inline monospace+uppercase styles in
StatusDropdown, TagsDropdown, ReferencedByPanel, AiActionCard replaced.
Tailwind uppercase removed from Sidebar, CommandPalette, PulseView,
CreateNoteDialog, CreateTypeDialog. Hardcoded ALL CAPS text (COLOR,
ICON, TEMPLATE) converted to sentence case. Code blocks and commit
hashes remain monospaced.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 17:00:04 +02:00

175 lines
5.3 KiB
TypeScript

import { type KeyboardEvent, type ReactNode, useCallback } from 'react'
import {
PencilSimple, MagnifyingGlass, Trash, ChartBar, Eye,
CircleNotch, CheckCircle, XCircle, CaretRight, CaretDown,
Terminal, File, FolderOpen, NotePencil,
} from '@phosphor-icons/react'
export type AiActionStatus = 'pending' | 'done' | 'error'
export interface AiActionCardProps {
tool: string
label: string
path?: string
status: AiActionStatus
input?: string
output?: string
expanded: boolean
onToggle: () => void
onOpenNote?: (path: string) => void
}
const MAX_DETAIL_LENGTH = 800
type IconRenderer = (size: number) => ReactNode
const TOOL_ICON_MAP: Record<string, IconRenderer> = {
// Native Claude Code tools
Bash: (s) => <Terminal size={s} />,
Write: (s) => <PencilSimple size={s} />,
Edit: (s) => <NotePencil size={s} />,
Read: (s) => <File size={s} />,
Glob: (s) => <FolderOpen size={s} />,
Grep: (s) => <MagnifyingGlass size={s} />,
// Laputa MCP tools
search_notes: (s) => <MagnifyingGlass size={s} />,
get_vault_context: (s) => <ChartBar size={s} />,
get_note: (s) => <File size={s} />,
open_note: (s) => <Eye size={s} />,
// Legacy tools (for backward compatibility with existing messages)
create_note: (s) => <PencilSimple size={s} />,
delete_note: (s) => <Trash size={s} />,
}
const DEFAULT_ICON: IconRenderer = (s) => <PencilSimple size={s} />
function StatusIndicator({ status }: { status: AiActionStatus }) {
if (status === 'pending') {
return <CircleNotch size={14} className="ai-spin text-muted-foreground" data-testid="status-pending" />
}
if (status === 'done') {
return <CheckCircle size={14} weight="fill" style={{ color: 'var(--accent-green)' }} data-testid="status-done" />
}
return <XCircle size={14} weight="fill" style={{ color: 'var(--destructive)' }} data-testid="status-error" />
}
function truncateText(text: string): { text: string; truncated: boolean } {
if (text.length <= MAX_DETAIL_LENGTH) return { text, truncated: false }
return { text: text.slice(0, MAX_DETAIL_LENGTH), truncated: true }
}
function formatInputForDisplay(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}
function DetailBlock({ label, content, isError }: {
label: string; content: string; isError?: boolean
}) {
const { text, truncated } = truncateText(content)
return (
<div style={{ marginTop: 6 }}>
<div
className="text-muted-foreground"
style={{ fontSize: 10, fontWeight: 600, marginBottom: 2 }}
>
{label}
</div>
<pre
data-testid={`detail-${label.toLowerCase()}`}
style={{
fontSize: 11,
lineHeight: 1.4,
margin: 0,
padding: '4px 6px',
borderRadius: 4,
background: 'var(--muted)',
color: isError ? 'var(--destructive)' : 'var(--foreground)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 200,
overflow: 'auto',
}}
>
{text}{truncated && <span className="text-muted-foreground">{'…'}</span>}
</pre>
</div>
)
}
/** Whether this tool is a Laputa UI-only tool (lighter styling). */
function isUiOnlyTool(tool: string): boolean {
return tool === 'open_note'
}
export function AiActionCard({
tool, label, path, status, input, output, expanded, onToggle, onOpenNote,
}: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const hasDetails = !!(input || output)
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onToggle()
} else if (e.key === 'Escape' && expanded) {
e.preventDefault()
onToggle()
}
}, [onToggle, expanded])
const handleClick = useCallback(() => {
if (path && onOpenNote && !hasDetails) {
onOpenNote(path)
} else {
onToggle()
}
}, [path, onOpenNote, hasDetails, onToggle])
const formattedInput = input ? formatInputForDisplay(input) : undefined
return (
<div
data-testid="ai-action-card"
className="rounded"
style={{
fontSize: 12,
background: isUiOnlyTool(tool) ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
}}
>
<div
className="flex items-center gap-2"
style={{ padding: '6px 10px', cursor: 'pointer' }}
role="button"
tabIndex={0}
aria-expanded={expanded}
onClick={handleClick}
onKeyDown={handleKeyDown}
data-testid="action-card-header"
>
<span className="shrink-0 text-muted-foreground" style={{ width: 14, display: 'flex' }}>
{hasDetails
? (expanded ? <CaretDown size={12} /> : <CaretRight size={12} />)
: renderIcon(14)}
</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
{expanded && hasDetails && (
<div
data-testid="action-card-details"
style={{ padding: '0 10px 8px 10px' }}
>
{formattedInput && <DetailBlock label="Input" content={formattedInput} />}
{output && (
<DetailBlock label="Output" content={output} isError={status === 'error'} />
)}
</div>
)}
</div>
)
}